Files
punktfunk/clients/windows/src/app/mod.rs
T
enricobuehlerandClaude Opus 5 f5de661c9e feat(client/windows): overridden rows are marked and resettable, and the speed test targets the right layer
The two remaining pieces of P1 on Windows, both closing the same kind of gap the Linux
client closed earlier.

Overridden rows in profile scope now say so — an accent-marked caption — and carry the
only way back to inheriting. Without it a profile is a one-way door here too, because
overrides are recorded on touch and never inferred from comparing values. Reset calls the
shared `SettingsOverlay::clear`, so the list of what can be cleared stays in one place
across both shells. It also bumps a revision counter: the catalog changes behind the
controls and nothing the page reads as state does, and root state compares before
re-rendering, so without the bump the row would keep showing the value it just dropped.

The speed test writes to the layer the TESTED host reads bitrate from, not the global —
measuring one host used to re-tune every other one. Resolved exactly the way a connect
resolves it: the one-off the test was started with (a pinned tile carries one), else the
host's binding, else the global; a dangling binding falls back. The button names its
target, so it says where it will write before you press it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:21:13 +02:00

548 lines
26 KiB
Rust

//! 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 spawned session's stdout reader) 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 ·
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
//! * [`stream`] — the stream status card (the stream itself runs in the spawned session window)
//! * [`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.
mod connect;
mod help;
mod hosts;
mod library;
mod licenses;
mod pair;
mod settings;
mod speed;
mod stream;
mod style;
use crate::discovery::{self, DiscoveredHost};
use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps;
use pf_client_core::gamepad::GamepadService;
use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
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,
/// Wake-on-LAN "wait until up": a magic packet was sent to an offline saved host and we're
/// polling mDNS for it to reappear (re-sending periodically) before dialing. Cancelable.
Waking,
Stream,
Settings,
/// Open-source / third-party license notices (reached from Settings).
Licenses,
/// In-stream keyboard-shortcuts reference + capture help (reached from the host list's
/// Shortcuts button).
Help,
Pair,
/// Per-host network speed test (probe burst + recommended bitrate).
SpeedTest,
/// The target host's game library (poster grid; tap-to-launch) — paired hosts only,
/// behind the "Show game library" experimental toggle.
Library,
}
/// 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,
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
/// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
/// `None` honors the binding. It never rebinds anything — the default changes only through
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
pub(crate) profile: Option<String>,
}
/// Stable app services handed to the page components as props. Each routed screen that uses
/// hooks (`hosts_page`/`pair_page`/`speed_page`/`library_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>,
/// Library fetch/art state — root for the same reason; the hosts page kicks a fetch
/// off before navigating, the worker (and the art stream) completes it.
pub(crate) set_library: AsyncSetState<library::LibraryState>,
}
impl PartialEq for Svc {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.ctx, &other.ctx)
}
}
/// Cross-thread shell state driven off the UI thread: the current target, the live spawned
/// session child (Disconnect/Cancel kill it) and its latest stats line, plus the connect-flow
/// cancel flag and the discovery/library/speed-test generation guards.
#[derive(Default)]
pub(crate) struct Shared {
pub(crate) target: Mutex<Target>,
/// The live session child (spawn mode) — the status page's Disconnect and the
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
pub(crate) session: Mutex<crate::spawn::SessionChild>,
/// Latest `stats:` line from the session child (spawn mode), already formatted;
/// mirrored into the HUD sample for the session status page.
pub(crate) stats_line: Mutex<String>,
/// 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,
/// Whether the live session child is a `--browse` console-UI run (vs a stream) — the
/// session status page words itself accordingly. Set by each spawn.
pub(crate) browse: std::sync::atomic::AtomicBool,
/// Library-fetch generation (the speed test's guard pattern): bumped per fetch so a
/// superseded worker (re-open, Retry, another host) stops publishing.
pub(crate) library_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()),
});
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
// whenever such a pad connects) — GTK-shell parity.
{
let forward = ctx.settings.lock().unwrap().forward_pad.clone();
if !forward.is_empty() {
ctx.gamepad.set_pinned(Some(forward));
}
}
apply_window_icon_when_ready();
App::new()
.title("Punktfunk")
.inner_size(1000.0, 720.0)
// A floor under every layout: below this the header/cards would clip rather than
// reflow (the pages adapt down to it — see the hosts page's responsive grid).
.inner_constraints(InnerConstraints {
min_width: Some(420.0),
min_height: Some(360.0),
..Default::default()
})
.backdrop(Backdrop::Mica)
.render(move |cx| root(cx, &ctx))
}
/// Stamp the embedded app icon (build.rs, resource ordinal 1) onto the top-level window once it
/// exists: `WM_SETICON` drives the title bar and Alt-Tab (plus the taskbar for unpackaged runs;
/// the MSIX taskbar/Start icons come from the package assets). windows-reactor creates its
/// window icon-less and exposes no handle before `App::render` blocks, so a short background
/// poll finds our own window by its (unique) title.
fn apply_window_icon_when_ready() {
use windows::Win32::Foundation::{LPARAM, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, GetSystemMetrics, LoadImageW, SendMessageW, ICON_BIG, ICON_SMALL, IMAGE_ICON,
LR_DEFAULTCOLOR, SM_CXICON, SM_CXSMICON, WM_SETICON,
};
let _ = std::thread::Builder::new()
.name("pf-window-icon".into())
.spawn(|| unsafe {
for _ in 0..100 {
if let Ok(hwnd) = FindWindowW(None, windows::core::w!("Punktfunk")) {
let Ok(module) = GetModuleHandleW(None) else {
return;
};
// Small (title bar) and big (Alt-Tab) at their native metrics, both from
// the multi-size .ico so nothing is scaled at draw time.
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
let px = GetSystemMetrics(metric);
if let Ok(icon) = LoadImageW(
Some(module.into()),
windows::core::PCWSTR(1 as *const u16),
IMAGE_ICON,
px,
px,
LR_DEFAULTCOLOR,
) {
SendMessageW(
hwnd,
WM_SETICON,
Some(WPARAM(which as usize)),
Some(LPARAM(icon.0 as isize)),
);
}
}
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
}
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);
// Per-host action state for the hosts page. Root, not page-local: the "…" overflow is a WinUI
// MenuFlyout whose item clicks are wired straight in the reactor backend, bypassing the normal
// event-dispatch flush — a sync page-local setter marks state dirty but never re-renders. See
// `hosts::HostsProps`.
let (forget, set_forget) = cx.use_async_state(Option::<(String, String)>::None);
let (rename, set_rename) = cx.use_async_state(Option::<(String, String)>::None);
let (show_add, set_show_add) = cx.use_async_state(false);
// Hovered host tile (its stable id), driving the WinUI-style card hover fill. Root state for
// the same reason as `forget`/`rename`: pointer enter/exit handlers are wired straight in the
// reactor backend, so only a root `AsyncSetState` reliably re-renders the page.
let (hover, set_hover) = cx.use_async_state(Option::<String>::None);
// Which Settings section the NavigationView shows (persists across visits this run).
// Opens on General — the first sidebar item, matching the Apple client's landing category.
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
// Which LAYER the settings screen edits: "" = the global defaults, else a profile id
// (design/client-settings-profiles.md §5.1). Root state for the same reason as the section
// above — the ComboBox's change handler is wired in the reactor backend.
let (settings_scope, set_settings_scope) = cx.use_async_state(String::new());
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
// because this page stays hook-free (its handlers are wired in the reactor backend).
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
// Bumped when a settings edit changes what the page should SHOW without changing any state
// it already reads — resetting an override, which rewrites the catalog behind the controls.
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
// Connected-controller count, mirrored from the gamepad service by a poll thread
// (thread-driven state must be root state — see the module docs). Drives the hosts
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
let (pads, set_pads) = cx.use_async_state(0usize);
cx.use_effect((), {
let (gp, set_pads) = (ctx.gamepad.clone(), set_pads.clone());
move || {
std::thread::Builder::new()
.name("pf-pads".into())
.spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(2));
set_pads.call(gp.pads().len());
})
.ok();
}
});
// Saved-host reachability, keyed by `fp_hex`, refreshed by the probe sweep below. Root state
// (thread-driven → must be root to re-render — see the module docs), passed to the hosts page.
let (probed, set_probed) = cx.use_async_state(HashMap::<String, bool>::new());
// Library fetch/art state (thread-driven → root; see `library::start_fetch`).
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
// Continuous LAN discovery (spawned once).
cx.use_effect((), {
let set_hosts = set_hosts.clone();
move || {
let rx = discovery::browse();
std::thread::spawn(move || {
let mut acc: Vec<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 spawned session child's latest `stats:` line, mirrored into root state so
// the stream status page gets it 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_line: shared.stats_line.lock().unwrap().clone(),
});
})
.ok();
}
});
// Periodic reachability sweep (spawned once): a saved host reached only over a routed network
// (Tailscale/VPN) never advertises on mDNS, so presence can't come from the discovery stream
// alone. This probes every saved host (bounded, trust-agnostic QUIC handshake — one thread each
// so a slow/unreachable host doesn't delay the rest) and mirrors the results into root state so
// the tiles get them as a prop; the pip then reads `advertising OR probed-reachable` — the
// display-side companion to the dial-first connect fix. The compare in `call` makes idle free.
cx.use_effect((), {
let set_probed = set_probed.clone();
let shared = ctx.shared.clone();
move || {
std::thread::Builder::new()
.name("pf-probe".into())
.spawn(move || loop {
// A spawned session/browse child is running: the shell is hidden
// (nobody sees the pips) and one of these hosts is mid-stream —
// probing it is pure noise. Sleep through and sweep after it ends.
if shared.session.lock().unwrap().is_running() {
std::thread::sleep(Duration::from_secs(12));
continue;
}
let handles: Vec<_> = KnownHosts::load()
.hosts
.into_iter()
.filter(|h| !h.addr.is_empty())
.map(|h| {
std::thread::spawn(move || {
(
h.fp_hex,
NativeClient::probe(
&h.addr,
h.port,
Duration::from_millis(2500),
),
)
})
})
.collect();
let map: HashMap<String, bool> =
handles.into_iter().filter_map(|h| h.join().ok()).collect();
set_probed.call(map);
std::thread::sleep(Duration::from_secs(12));
})
.ok();
}
});
// Screen-entrance animation: each navigation slides the new screen up a few px while fading it
// in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because
// reactor's DSL exposes no static transform/translation setter and its one-shot animations run
// from the visual's CURRENT value (a shown element is already at opacity 1, so nothing to fade
// from). So a worker thread steps a 0 → 1 `progress` after each navigation; the wrapper maps it
// to opacity (= progress) and a top margin (= (1-progress)·offset). The page components are
// memoised on unchanged props, so each step is just a cheap root re-render updating two props.
// A generation guard (bumped per navigation) stops a superseded tween so rapid nav can't fight.
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
cx.use_effect(screen.clone(), {
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
return; // a newer navigation superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
let eased = 1.0 - (1.0 - p).powi(3); // ease-out cubic
set_anim.call((Some(s.clone()), eased));
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
}
});
// Progress for THIS screen: 0 until the tween for it starts (fresh navigation starts hidden +
// offset, no flash), 1 once settled. A stale value for another screen reads as 0.
let progress = if anim.0.as_ref() == Some(&screen) {
anim.1
} else {
0.0
};
// Settings-section entrance: the same tween again, keyed on the selected section, so
// switching panes slides the CONTENT column up (the sidebar stays put — this must not wrap
// the NavigationView, so it can't ride the screen-level tween above). Entering Settings
// fresh leaves it settled at 1 (only the screen tween plays; no double animation).
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
cx.use_effect(settings_nav.clone(), {
let (s, set_nav_anim, gen) = (
settings_nav.clone(),
set_nav_anim.clone(),
nav_gen.borrow().clone(),
);
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
return; // a newer section switch superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
let eased = 1.0 - (1.0 - p).powi(3);
set_nav_anim.call((s.clone(), eased));
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
}
});
let nav_progress = if nav_anim.0 == settings_nav {
nav_anim.1
} else {
0.0
};
// "Add host" modal entrance: the same manual tween as the screen navigation (see above for
// why it can't be a composition animation), stepping 0 → 1 when the modal opens. The hosts
// page maps it to the modal's opacity + a downward start offset (the slide-up) and the
// scrim's fade. Closing resets to 0 instantly — the modal unmounts, nothing to animate.
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
cx.use_effect(show_add, {
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
if !show_add {
set_add_anim.call(0.0);
return;
}
std::thread::spawn(move || {
const STEPS: u32 = 12;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
return; // reopened/closed mid-tween — a newer run owns the value
}
let p = f64::from(i) / f64::from(STEPS);
set_add_anim.call(1.0 - (1.0 - p).powi(3)); // ease-out cubic
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
}
});
// 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(),
set_library: set_library.clone(),
};
let body = match &screen {
Screen::Hosts => component(
hosts::hosts_page,
HostsProps {
svc,
hosts,
probed,
status,
pads,
forget,
rename,
show_add,
add_anim,
hover,
set_forget,
set_rename,
set_show_add,
set_hover,
},
),
// connecting_page / request_access_page / waking_page / settings_page / licenses_page /
// help_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::Waking => connect::waking_page(ctx, &set_screen),
Screen::Settings => settings::settings_page(
ctx,
&set_screen,
&settings_nav,
&set_settings_nav,
&settings_scope,
&set_settings_scope,
&settings_delete,
&set_settings_delete,
settings_rev,
&set_settings_rev,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
Screen::Library => component(
library::library_page,
library::LibraryProps {
svc,
state: library,
},
),
// The stream runs in the punktfunk-session child's own window; this screen is a
// status page (no hooks — inline is sound).
Screen::Stream => stream::session_page(ctx, &hud),
};
// The Stream screen is a plain status card (the session child owns the real stream window);
// it's shown without the navigation entrance tween. Everything else slides + fades in.
if matches!(screen, Screen::Stream) {
return body;
}
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
}