Files
punktfunk/clients/windows/src/app/hosts.rs
T
enricobuehler efb49c5afd
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m9s
apple / swift (push) Successful in 1m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 41s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 46s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 5m22s
arch / build-publish (push) Successful in 6m57s
ci / web (push) Successful in 57s
ci / rust (push) Successful in 5m20s
ci / docs-site (push) Successful in 1m1s
deb / build-publish (push) Successful in 3m20s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
flatpak / build-publish (push) Successful in 4m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m22s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m39s
feat(windows-client): WOL wait-until-up + IP re-key (Apple/Android parity)
Like the Linux client, the Windows client had WOL send + MAC storage + a Wake
action + fire-and-forget auto-wake, but no poll-until-up + IP re-key. Add the
polished flow (mirrors Apple HostWaker + the request_access screen pattern):

- connect::wake_and_connect — send the magic packet, show a cancelable
  Screen::Waking busy page, poll discovery::browse() until the host reappears
  (re-sending every 6 s, 90 s budget), then dial; re-key the saved host
  (KnownHosts::upsert) if it woke on a new IP.
- Screen::Waking + waking_page, routed in app/mod.rs (mirrors RequestAccess).
- the saved-host tile routes an offline-with-MAC tap to wake_and_connect;
  MENU_WAKE stays a pure send-only button.

Reviewed against the request_access reference — DiscoveredHost/KnownHost/Target
types, the widgets, .call()/.lock(), and the initiate signature all match;
compile-verified by Windows CI (no local Windows toolchain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:50:51 +00:00

576 lines
22 KiB
Rust

//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, wake_and_connect};
use super::speed::SpeedState;
use super::style::*;
use super::{Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::trust::KnownHosts;
use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host";
const MENU_RENAME: &str = "Rename\u{2026}";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles.
const TILE_MIN_WIDTH: f64 = 320.0;
const TILE_GAP: f64 = 12.0;
/// 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).
///
/// `forget` and `rename` are the per-host action state, and they live in ROOT (not this page's
/// own `use_state`) on purpose: the "…" overflow is a WinUI `MenuFlyout`, whose item clicks are
/// wired directly in the reactor backend (`add_Click`) and so bypass the normal event-dispatch
/// flush — a *sync* child `SetState` from that handler marks state dirty but never pumps the
/// reconciler, so nothing re-renders. Root `AsyncSetState` re-renders the whole tree; because
/// these values are props, the changed value propagates back into this page (a child's own async
/// state would be memoised away when its props are unchanged). `(fp_hex, _)` in each identifies
/// the target saved host; `rename`'s second field is the in-progress draft name.
#[derive(Clone)]
pub(crate) struct HostsProps {
pub(crate) svc: Svc,
pub(crate) hosts: Vec<DiscoveredHost>,
pub(crate) status: String,
pub(crate) forget: Option<(String, String)>,
pub(crate) rename: Option<(String, String)>,
/// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's
/// own `use_state`: a child component's sync `SetState` marks its slot dirty but does not
/// re-render when its props are otherwise unchanged, so the toggle wouldn't take.
pub(crate) show_add: bool,
/// The modal's entrance-tween progress (0 → 1, root-driven): opacity + slide-up offset.
pub(crate) add_anim: f64,
/// The hovered tile's stable id (saved: fp_hex, discovered: `addr:port`) — root state because
/// the pointer enter/exit handlers bypass the reconciler flush, like the flyout clicks above.
pub(crate) hover: Option<String>,
pub(crate) set_forget: AsyncSetState<Option<(String, String)>>,
pub(crate) set_rename: AsyncSetState<Option<(String, String)>>,
pub(crate) set_show_add: AsyncSetState<bool>,
pub(crate) set_hover: AsyncSetState<Option<String>>,
}
impl PartialEq for HostsProps {
fn eq(&self, other: &Self) -> bool {
// Setters are identity-stable; only the value fields drive re-render.
self.svc == other.svc
&& self.hosts == other.hosts
&& self.status == other.status
&& self.forget == other.forget
&& self.rename == other.rename
&& self.show_add == other.show_add
&& self.add_anim == other.add_anim
&& self.hover == other.hover
}
}
/// A host tile. The tap-to-connect summary (monogram, name, address, status row) and the
/// optional "…" menu button are SIBLINGS overlaid in one grid cell, never nested: WinUI bubbles
/// `Tapped` out of buttons (reactor doesn't mark it handled), so a button inside the tap target
/// would fire both its own click and the tile's connect (the old forget-also-connects bug).
///
/// Hover renders the WinUI card pointer-over look — the card background lifts to the control
/// hover fill while the pointer is inside the tile (tracked via `hover`, see `HostsProps`).
fn host_tile(
id: &str,
hover: &Hover,
name: &str,
sub: &str,
status_row: Element,
menu: Option<Button>,
on_tap: Option<Box<dyn Fn()>>,
) -> Element {
let mut summary = border(
vstack((
avatar(name)
.width(44.0)
.height(44.0)
.horizontal_alignment(HorizontalAlignment::Left),
text_block(name)
.font_size(15.0)
.semibold()
.margin(edges(0.0, 12.0, 0.0, 0.0)),
text_block(sub)
.font_size(12.0)
.font_family("Consolas")
.foreground(ThemeRef::SecondaryText)
.margin(edges(0.0, 2.0, 0.0, 0.0)),
status_row,
))
.spacing(0.0),
)
.background(hit_test_backstop())
.padding(uniform(18.0));
if let Some(f) = on_tap {
summary = summary.on_tapped(f);
}
let mut children: Vec<Element> = vec![summary.into()];
if let Some(m) = menu {
children.push(
m.horizontal_alignment(HorizontalAlignment::Right)
.vertical_alignment(VerticalAlignment::Top)
.margin(edges(0.0, 8.0, 8.0, 0.0))
.into(),
);
}
let mut tile = card_flush(grid(children));
if hover.current.as_deref() == Some(id) {
tile = tile.background(ThemeRef::ControlFillSecondary);
}
let enter = {
let (set, id) = (hover.set.clone(), id.to_string());
move |_: PointerEventInfo| set.call(Some(id.clone()))
};
let exit = {
let set = hover.set.clone();
move || set.call(None)
};
tile.on_pointer_entered(enter)
.on_pointer_exited(exit)
.into()
}
/// The hover-tracking pair `host_tile` needs: the currently hovered tile id + its root setter.
pub(crate) struct Hover {
pub(crate) current: Option<String>,
pub(crate) set: AsyncSetState<Option<String>>,
}
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
let mut items: Vec<Element> = Vec::new();
if let Some(online) = online {
items.push(
presence_dot(online)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
items.push(
text_block(if online { "Online" } else { "Offline" })
.font_size(11.0)
.foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
items.push(
pill(badge, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
hstack(items)
.spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0))
.into()
}
/// Lay tiles into a `cols`-wide grid of equal-width star columns (rows share the height of
/// their tallest tile, so a grid row always lines up).
fn tile_grid(tiles: Vec<Element>, cols: usize) -> Element {
let rows = tiles.len().div_ceil(cols);
let mut children = Vec::with_capacity(tiles.len());
for (i, t) in tiles.into_iter().enumerate() {
children.push(t.grid_row((i / cols) as i32).grid_column((i % cols) as i32));
}
grid(children)
.columns(vec![GridLength::Star(1.0); cols])
.rows(vec![GridLength::Auto; rows])
.column_spacing(TILE_GAP)
.row_spacing(TILE_GAP)
.into()
}
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
fn rename_editor(
draft: &str,
fp: String,
set_rename: AsyncSetState<Option<(String, String)>>,
) -> Element {
let commit = {
let (fp, draft, sr) = (fp.clone(), draft.to_string(), set_rename.clone());
move || {
let name = draft.trim();
if !name.is_empty() {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.name = name.to_string();
}
let _ = known.save();
}
sr.call(None);
}
};
let on_changed = {
let sr = set_rename.clone();
move |s: String| sr.call(Some((fp.clone(), s)))
};
card(
vstack((
text_box(draft)
.placeholder_text("Host name")
.on_text_changed(on_changed),
hstack((
button("Save")
.accent()
.icon(Symbol::Accept)
.on_click(commit),
button("Cancel")
.subtle()
.on_click(move || set_rename.call(None)),
))
.spacing(4.0),
))
.spacing(10.0),
)
.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());
// "Add host" modal open state lives in ROOT (see `HostsProps`).
let show_add = props.show_add;
let set_show_add = &props.set_show_add;
// Forget confirmation and in-progress rename live in ROOT state (see `HostsProps`) — the
// overflow menu's flyout clicks can't re-render off a sync setter. Both are `(fp_hex, _)`.
let forget = props.forget.clone();
let rename = props.rename.clone();
let set_forget = &props.set_forget;
let set_rename = &props.set_rename;
let hover = Hover {
current: props.hover.clone(),
set: props.set_hover.clone(),
};
let known = KnownHosts::load();
// Responsive column count from the live window width (re-renders on resize): as many
// TILE_MIN_WIDTH columns as fit the page's content width, at least one.
let window = cx.use_inner_size();
let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0);
let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1);
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),
hstack((
button("Add host").accent().icon(Symbol::Add).on_click({
let sa = set_show_add.clone();
move || sa.call(true)
}),
button("Settings").icon(Symbol::Setting).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
}),
))
.spacing(8.0)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
))
.columns([GridLength::Star(1.0), GridLength::Auto])
.margin(edges(0.0, 0.0, 0.0, 10.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. A saved host that's also
// being advertised right now shows as Online (and is deduped out of the discovery section).
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new();
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
let (fp, draft) = rename.clone().unwrap();
tiles.push(rename_editor(&draft, fp, set_rename.clone()));
continue;
}
let target = Target {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
mac: k.mac.clone(),
};
let online = hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
// it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& !h.mac.is_empty()
}) {
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
let can_wake = !online && !k.mac.is_empty();
let menu = {
let (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
// Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake {
items.push(menu_item(MENU_WAKE));
}
items.push(menu_item(MENU_RENAME));
items.push(menu_separator());
items.push(menu_item(MENU_FORGET));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe, 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);
}
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
_ => {}
})
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
tiles.push(host_tile(
&k.fp_hex,
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row(
Some(online),
if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info },
),
Some(menu),
Some(Box::new(move || {
// Offline saved host with a known MAC: wake it and WAIT for it to reappear on
// the network (re-sending periodically) before dialing — a cold box boots far
// slower than a connect will sit. An online host dials straight away.
if can_wake {
wake_and_connect(&ctx2, target.clone(), &ss, &st);
} else {
initiate(&ctx2, target.clone(), &ss, &st);
}
})),
));
}
body.push(tile_grid(tiles, cols));
}
// Discovered hosts not already saved above.
body.push(section("ON THIS NETWORK"));
let discovered: Vec<&DiscoveredHost> = hosts
.iter()
.filter(|h| {
!known.hosts.iter().any(|k| {
(!h.fp_hex.is_empty() && k.fp_hex == h.fp_hex)
|| (k.addr == h.addr && k.port == h.port)
})
})
.collect();
if discovered.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 {
let mut tiles: Vec<Element> = Vec::new();
for h in discovered {
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",
mac: h.mac.clone(),
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" {
("PIN", Pill::Info)
} else {
("Open", Pill::Neutral)
};
tiles.push(host_tile(
&format!("{}:{}", h.addr, h.port),
&hover,
&h.name,
&format!("{}:{}", h.addr, h.port),
status_row(None, badge, kind),
None,
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
));
}
body.push(tile_grid(tiles, cols));
}
// 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(),
);
}
let page = page_wide(body);
if !show_add {
return page;
}
// "Add host" modal: a scrim + centered card. It's an in-tree overlay, not a WinUI
// ContentDialog, because ContentDialog is text-only in windows-reactor (no room for a text
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
let connect_manual = {
let (ctx2, ss, st, text, sa) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual.clone(),
set_show_add.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),
};
sa.call(false);
initiate(
&ctx2,
Target {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_optional: false,
mac: Vec::new(),
},
&ss,
&st,
);
}
};
let modal = dialog_surface(
vstack((
text_block("Add a host").font_size(20.0).bold(),
text_block(
"Enter the host's IP address or name. Append :port only for a non-standard port \
(the default is 9777).",
)
.font_size(13.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
text_box(manual)
.header("Address")
.placeholder_text("192.168.1.20 or my-pc.local")
.on_text_changed(move |s| set_manual.call(s))
.margin(edges(0.0, 6.0, 0.0, 0.0)),
hstack((
button("Connect")
.accent()
.icon(Symbol::Forward)
.on_click(connect_manual),
button("Cancel").on_click({
let sa = set_show_add.clone();
move || sa.call(false)
}),
))
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Right)
.margin(edges(0.0, 6.0, 0.0, 0.0)),
))
.spacing(12.0),
)
.max_width(460.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
// Entrance: fade + slide up, driven by the root tween (`add_anim` 0 → 1). The card starts
// a bit low and rises to centre — for a centred element, extra top margin shifts it down by
// half the difference, so the offset is doubled.
.opacity(props.add_anim)
.margin(edges(
24.0,
24.0 + (1.0 - props.add_anim) * 56.0,
24.0,
24.0,
));
// The scrim fades in with the same tween.
let scrim = border(modal).background(Color {
a: (140.0 * props.add_anim) as u8,
r: 0,
g: 0,
b: 0,
});
grid(vec![page, scrim.into()]).into()
}