Files
punktfunk/clients/windows/src/app/hosts.rs
T
enricobuehlerandClaude Fable 5 fd75e66041 feat(windows): the shell spawns from the shared brain, and links, tests and pairing stop losing things
Windows-shell parity gaps from the 2026-07-31 sweep, closed:

* The spawner goes through the shared brain: `ConnectPlan::for_target` →
  `session_args()` + a written `--resolved-spec`, replacing the
  hand-assembled argv that meant Windows sessions ALWAYS took the compat
  path (re-resolving every setting from the stores — the drift
  orchestrate.rs documents as a trap) and that any field added to the
  spec was silently Windows-dead. Fullscreen now comes from the plan's
  effective settings (profile-aware) instead of a caller argument, the
  spec temp file is cleaned up at exit, and the reader finally parses
  the `{"window":…}` line so the SPAWNER persists the match-window size
  (§5) instead of the renderer's load-modify-save fallback.
* A deep link to an unpaired host runs the trust ceremony instead of
  refusing: the Pair screen opens seeded with what the link CLAIMED —
  name shown as claimed, fingerprint carried, and the launch/profile
  surviving the detour (`Target` grew a `launch` field for exactly
  this). GTK parity; a shared link was a dead end here before.
* The speed test learns the Ask tier: a bound host whose profile
  INHERITS bitrate now offers both "Set as default" and "Set in
  profile" instead of silently creating a profile override. The global
  write (and the forwarded-controller handler) also rebase on the file
  before their whole-struct saves — two more stale-snapshot writers.
* The Controllers card shows the detected-pad inventory (read-only,
  with the Steam-Input-virtual note) — the fastest answer to "is my
  controller even detected?", present on GTK all along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:05 +02:00

1169 lines
51 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 / edit /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::{initiate, initiate_waking, open_console};
use super::speed::SpeedState;
use super::style::*;
use super::{Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::trust::KnownHosts;
use std::collections::HashMap;
use windows_reactor::*;
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect";
const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host";
/// One entry for every per-host property (name, address, MAC, clipboard sharing) — the
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter.
const MENU_EDIT: &str = "Edit\u{2026}";
/// The per-profile families nest in submenus. Submenu LEAVES are what the shared click
/// callback reports (the backend wires clicks recursively and hands back the leaf text):
/// "Connect with"'s leaves are the bare profile names + [`SUB_WITH_DEFAULT`]; "Pin tiles"'s
/// leaves keep a verb prefix, which is what tells the two families apart in the callback.
/// (A profile literally named like a fixed entry, e.g. "Connect", is shadowed by it — the
/// same last-wins rule the scope dropdown documents.)
const SUB_WITH: &str = "Connect with";
const SUB_WITH_DEFAULT: &str = "Default settings";
const SUB_PIN: &str = "Pin tiles";
const MENU_COPY_LINK: &str = "Copy link";
const MENU_SHORTCUT: &str = "Create shortcut\u{2026}";
const MENU_PIN: &str = "Pin tile: ";
const MENU_UNPIN: &str = "Unpin tile: ";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships
/// its Skia `ui` feature on x64 only (no skia prebuilts for aarch64 yet) — the entry
/// points compile everywhere but only show where `--browse` can actually run.
const CONSOLE_UI_AVAILABLE: bool = cfg!(target_arch = "x86_64");
/// 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>,
/// Saved hosts proven reachable by the periodic QUIC probe (keyed by `fp_hex`), OR'd with
/// live-advert presence to drive the Online pip — so a host reached only over a routed
/// network (Tailscale/VPN), which never advertises on mDNS, still reads Online.
pub(crate) probed: HashMap<String, bool>,
pub(crate) status: String,
/// Connected-controller count (root state, mirrored from the gamepad service) — a
/// pad plus a paired host surfaces the "Open console UI" hint card.
pub(crate) pads: usize,
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>,
/// Bumped when a menu action changes what the page should SHOW without changing any
/// state it already reads — pinning/unpinning a profile tile, which rewrites the
/// known-hosts store behind the tiles (the hosts-page mirror of `settings_rev`).
pub(crate) hosts_rev: u64,
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>>,
pub(crate) set_hosts_rev: AsyncSetState<u64>,
}
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.probed == other.probed
&& self.status == other.status
&& self.pads == other.pads
&& self.forget == other.forget
&& self.rename == other.rename
&& self.show_add == other.show_add
&& self.add_anim == other.add_anim
&& self.hover == other.hover
&& self.hosts_rev == other.hosts_rev
}
}
/// 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()
.wrap()
.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: the host's OS mark (when advertised), presence
/// dot + Online/Offline, plus a trust chip only where it says something (see
/// [`status_row_with`]).
fn status_row(os: &str, online: Option<bool>, badge: Option<(&str, Pill)>) -> Element {
status_row_with(os, online, badge, None)
}
/// [`status_row`] plus the profile: what a plain click on THIS tile will use — its own
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
/// connect (design §6).
///
/// The row is METADATA, not a badge shelf — three chips side by side read as noise. Paired
/// is the normal resting state of a saved host, so it earns NO chip at all; a chip appears
/// only where it carries a decision ("Trusted" = TOFU without pairing, "PIN"/"Open" on a
/// discovered host). The profile is a small dot in the profile's own colour plus its name
/// in plain caption text — recognisable at a glance without competing with the host name.
fn status_row_with(
os: &str,
online: Option<bool>,
badge: Option<(&str, Pill)>,
profile: Option<(&str, Option<String>)>,
) -> Element {
let mut items: Vec<Element> = Vec::new();
// The OS mark leads the row; nothing at all for an older host that doesn't advertise
// one, so those tiles render exactly as they always did. Raster at 16px from the
// materialized cache (reactor has no vector element); the raw chain is the tooltip.
if let Some(uri) = super::os_icons::uri(os) {
items.push(
Image::new_with_uri(uri)
.width(16.0)
.height(16.0)
.tooltip(os)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
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(),
);
}
if let Some((badge, kind)) = badge {
items.push(
pill(badge, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
if let Some((name, accent)) = profile {
// The profile's own colour where it has one, a neutral disc where it doesn't — the
// palette stays opt-in, and an unparsable value falls back rather than being trusted.
let colour = accent
.as_deref()
.and_then(super::settings::hex_color)
.unwrap_or(Color {
a: 120,
r: 128,
g: 128,
b: 128,
});
items.push(
border(vstack(Vec::<Element>::new()).width(8.0).height(8.0))
.background(colour)
.corner_radius(4.0)
.margin(edges(
if items.is_empty() { 0.0 } else { 4.0 },
0.0,
0.0,
0.0,
))
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
items.push(
text_block(name)
.font_size(11.0)
.foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
hstack(items)
.spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0))
.into()
}
/// The in-tile host editor (a ContentDialog can't hold text fields): every per-host
/// property in one place, mirroring the Apple client's add/edit sheet — name, address,
/// port, Wake-on-LAN MAC, and whether this machine shares its clipboard with the host.
/// Replaced a menu-item-per-property, which buried the useful entries in noise.
///
/// Drafts live in refs owned by the page and are read at Save time; the root `edit` state
/// carries only the target's fingerprint + initial name, so typing doesn't round-trip
/// through a re-render.
#[allow(clippy::too_many_arguments)]
fn edit_editor(
fp: &str,
initial_name: &str,
name_draft: HookRef<String>,
addr_draft: HookRef<String>,
port_draft: HookRef<String>,
mac_draft: HookRef<String>,
clip_draft: HookRef<bool>,
set_edit: AsyncSetState<Option<(String, String)>>,
) -> Element {
let commit = {
let (fp, se) = (fp.to_string(), set_edit.clone());
let (name_draft, addr_draft, port_draft, mac_draft, clip_draft) = (
name_draft.clone(),
addr_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
);
move || {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
// Each field falls back to what was stored: a cleared box means "leave it",
// never "erase it" — except the MAC, which is legitimately clearable.
let name = name_draft.borrow().trim().to_string();
if !name.is_empty() {
h.name = name;
}
let addr = addr_draft.borrow().trim().to_string();
if !addr.is_empty() {
h.addr = addr;
}
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
if p != 0 {
h.port = p;
}
}
let mac = mac_draft.borrow().trim().to_string();
h.mac = if mac.is_empty() {
Vec::new()
} else {
mac.split(&[',', ' '][..])
.filter(|m| !m.trim().is_empty())
.map(|m| m.trim().to_string())
.collect()
};
h.clipboard_sync = *clip_draft.borrow();
}
let _ = known.save();
se.call(None);
}
};
// The profile binding: what a plain click on this tile will use. It commits on change
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
// fields are text boxes that genuinely need one.
let profile_picker = {
let catalog = pf_client_core::profiles::ProfilesFile::load();
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp)
.and_then(|h| h.profile_id.clone());
let mut names = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
names.push(p.name.clone());
ids.push(p.id.clone());
}
// A binding whose profile is gone reads as Default settings — the same "dangling
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
let current = stored
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let fp = fp.to_string();
ComboBox::new(names)
.header("Profile")
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.profile_id = (!id.is_empty()).then(|| id.clone());
let _ = known.save();
}
})
};
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack((
text_block(label)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Left),
text_box(&value)
.placeholder_text(placeholder)
.on_text_changed(move |t: String| draft.set(t)),
))
.spacing(2.0)
};
let (name0, addr0, port0, mac0, clip0) = (
name_draft.borrow().clone(),
addr_draft.borrow().clone(),
port_draft.borrow().clone(),
mac_draft.borrow().clone(),
*clip_draft.borrow(),
);
// A centred SHEET (scrim + card), not an in-grid tile: as a tile the editor inherited a
// grid cell in the middle of the page, and on an ordinary window its lower half sat
// below the fold with nothing hinting at it (live-diagnosed 2026-07-29: a control's
// visible rect was a 9-px sliver). A sheet centres at its own height — and its content
// sits in a scroll_view, so a short window scrolls the card instead of clipping it.
// A tap on the scrim, or Escape, cancels (a tap INSIDE the card bubbles to the scrim —
// the flag makes the scrim swallow exactly that one).
let inside_tap = std::rc::Rc::new(std::cell::Cell::new(false));
let cancel = {
let se = set_edit.clone();
move || se.call(None)
};
let modal = dialog_surface(scroll_view(
vstack((
text_block(format!("Edit \u{201c}{initial_name}\u{201d}"))
.font_size(20.0)
.bold(),
field("Name", name0, "e.g. Living Room", name_draft),
field("Address", addr0, "IP or hostname", addr_draft),
field("Port", port0, "9777", port_draft),
field(
"MAC (Wake-on-LAN)",
mac0,
"auto-filled when known",
mac_draft,
),
vstack((
profile_picker,
text_block(
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
in the tile\u{2019}s menu overrides it for one session without changing it.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
vstack((
ToggleSwitch::new(clip0)
.header("Share clipboard with this host")
.on_content("On")
.off_content("Off")
.on_toggled(move |v: bool| clip_draft.set(v)),
text_block(
"Copy on one machine, paste on the other. Off for every host until you \
turn it on here; the host must allow it too.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
hstack((
button("Save")
.accent()
.icon(Symbol::Accept)
.on_click(commit),
button("Cancel")
.subtle()
.on_click(move || set_edit.call(None)),
))
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Right),
))
.spacing(10.0),
))
.on_tapped({
let inside_tap = inside_tap.clone();
move || inside_tap.set(true)
})
.max_width(460.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0));
let scrim_cancel = cancel.clone();
Element::from(
border(modal)
.background(Color {
a: 140,
r: 0,
g: 0,
b: 0,
})
.on_tapped(move || {
if inside_tap.replace(false) {
return;
}
scrim_cancel();
}),
)
.keyboard_accelerator(KeyboardAccelerator::new(
VirtualKey::Escape,
VirtualKeyModifiers::None,
cancel,
))
}
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());
// The Add-host field's live value, read by Connect at click time. This page's `use_state` is
// unreliable as the click's source of truth: while the modal is open the page usually has no
// reason to re-render (you open it precisely because the host ISN'T being discovered, so no
// discovery tick fires), and the top-down reconcile skips this unchanged-props subtree — so a
// sync `set_manual` write never re-renders the Connect button to re-capture the address, and it
// would connect to the empty mount-time value. Mirror every keystroke into this stable ref (the
// pair-screen PIN pattern). `manual` still drives the text box's displayed value.
let manual_live = cx.use_ref(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;
// The live edit drafts, read at Save time (see `edit_editor`). Root `rename` carries only
// the target's fingerprint + initial name, so typing never round-trips through a
// re-render. Every draft is re-seeded from the STORED host whenever the edit target
// changes (open, cancel, or switching to another host).
let name_draft = cx.use_ref(String::new());
let addr_draft = cx.use_ref(String::new());
let port_draft = cx.use_ref(String::new());
let mac_draft = cx.use_ref(String::new());
let clip_draft = cx.use_ref(false);
let edit_seed = cx.use_ref(Option::<String>::None);
{
let active = rename.as_ref().map(|(fp, _)| fp.clone());
if *edit_seed.borrow() != active {
let stored = active.as_ref().and_then(|fp| {
KnownHosts::load()
.hosts
.into_iter()
.find(|h| &h.fp_hex == fp)
});
name_draft.set(stored.as_ref().map(|h| h.name.clone()).unwrap_or_default());
addr_draft.set(stored.as_ref().map(|h| h.addr.clone()).unwrap_or_default());
port_draft.set(
stored
.as_ref()
.map(|h| h.port.to_string())
.unwrap_or_default(),
);
mac_draft.set(
stored
.as_ref()
.map(|h| h.mac.join(", "))
.unwrap_or_default(),
);
clip_draft.set(stored.as_ref().is_some_and(|h| h.clipboard_sync));
edit_seed.set(active);
}
}
let hover = Hover {
current: props.hover.clone(),
set: props.set_hover.clone(),
};
let known = KnownHosts::load();
// The experimental library gate ("Show game library" in Settings) — GTK/Apple parity.
let library_enabled = ctx.settings.lock().unwrap().library_enabled;
// 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 + the page actions. ONE labelled primary — Add host, in accent —
// and the rest icon-only with tooltips: four written-out buttons in a row read as four
// competing calls to action (review feedback), and icon-only needs no compact-width
// special case either.
let icon_btn =
|label: &str, sym: Symbol| button("").icon(sym).tooltip(label).automation_name(label);
body.push(
grid((
vstack((
text_block("Punktfunk").font_size(30.0).bold(),
text_block("Stream from a host on your network.")
.wrap()
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
hstack({
let mut actions: Vec<Element> = vec![button("Add host")
.icon(Symbol::Add)
.accent()
.on_click({
let sa = set_show_add.clone();
move || sa.call(true)
})
.into()];
// The couch UI's front door, beside the other page actions. Absent on ARM64,
// where the session binary ships without its Skia console.
if CONSOLE_UI_AVAILABLE {
actions.push(
icon_btn(
"Console UI \u{2014} the controller-driven couch interface",
Symbol::Play,
)
.on_click({
let (c, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
// No target: the console opens its OWN host view rather than
// one host's library — the couch counterpart of this page.
move || open_console(&c, None, &ss, &st)
})
.into(),
);
}
actions.push(
icon_btn("Keyboard shortcuts", Symbol::Keyboard)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Help)
})
.into(),
);
actions.push(
icon_btn("Settings", Symbol::Setting)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
})
.into(),
);
actions
})
.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();
// One catalog read per render, shared by every tile's menu and chip.
let profiles: Vec<(String, String, Option<String>)> =
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name, p.accent))
.collect();
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,
mac: k.mac.clone(),
profile: None,
launch: None,
};
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
// covers a routed/Tailscale host that never advertises — the display companion to
// dial-first).
let online = hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
// 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);
}
// Same for its OS chain — the tile's mark then survives the host going offline.
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
}) {
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
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());
let menu_profiles = profiles.clone();
let pinned_now = k.pinned_profiles.clone();
let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone());
let (link_host, link_profile) = (k.clone(), None::<String>);
let shortcut_host = k.clone();
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
// Kept short deliberately, and in sections. It had grown into a list of
// everything, with the entries you actually reach for (connect, library,
// speed) buried in list management. The per-profile families nest in
// SUBMENUS — one "Connect with" and one "Pin tiles" — so the top level
// stays a fixed handful whatever the catalog grows to.
let mut items = vec![menu_item(MENU_CONNECT)];
// One-off connects: "Connect with" NEVER rebinds the host. Submenu
// leaves report their own text, so the leaf names stay bare.
if !profiles.is_empty() {
let mut leaves: Vec<MenuItemDef> = profiles
.iter()
.map(|(_, name, _)| menu_item(name.clone()))
.collect();
leaves.push(menu_item(SUB_WITH_DEFAULT));
items.push(menu_sub_item(SUB_WITH, leaves));
}
items.push(menu_separator());
// The library surfaces — mouse/KB page and the gamepad console UI — for
// paired hosts only (the mgmt API needs the paired identity); the page
// additionally sits behind the experimental toggle.
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_SPEED));
// 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_separator());
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_item(MENU_SHORTCUT));
// Pin/unpin a profile's one-click tile, beside the other tile-shaped
// shortcuts. The verb prefixes stay on the leaves: "Connect with"'s
// leaves are bare names, and the shared click callback only gets the
// leaf text — the prefix is what keeps the two families apart.
if !profiles.is_empty() {
let leaves: Vec<MenuItemDef> = profiles
.iter()
.map(|(id, name, _)| {
let pinned = pinned_now.iter().any(|x| x == id);
menu_item(format!(
"{}{name}",
if pinned { MENU_UNPIN } else { MENU_PIN }
))
})
.collect();
items.push(menu_sub_item(SUB_PIN, leaves));
}
items.push(menu_separator());
items.push(menu_item(MENU_EDIT));
items.push(menu_item(MENU_FORGET));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
// The profile items are dynamic, so they are matched by prefix before
// the fixed ones.
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
let (on, name) = if let Some(n) = item.strip_prefix(MENU_PIN) {
(true, n)
} else {
(false, item.trim_start_matches(MENU_UNPIN))
};
let Some((id, ..)) = menu_profiles.iter().find(|(_, n, _)| n == name)
else {
return;
};
tracing::info!(pin = %id, host = %fp, on, "pin toggle");
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|x| x != id);
if on {
h.pinned_profiles.push(id.clone());
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving a pin");
}
}
// The store changed behind the tiles and nothing the page reads
// as state did — the bump is what makes the pinned tile appear
// (or vanish) NOW, not on the next discovery tick.
set_hosts_rev.call(hosts_rev + 1);
}
MENU_SHORTCUT => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&shortcut_host,
None,
None,
)
.to_url();
match crate::deeplink::write_shortcut(&shortcut_host.name, &url) {
Ok(p) => tracing::info!(path = %p.display(), "shortcut written"),
Err(e) => tracing::warn!(error = %e, "writing the shortcut"),
}
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
link_profile.as_deref(),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_LIBRARY => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
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_EDIT => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
// "Connect with"'s submenu leaves: a bare profile name, or
// SUB_WITH_DEFAULT. `Some("")` — not `None` — so Default settings
// really does override a bound host for this one connect.
other => {
let profile_id = if other == SUB_WITH_DEFAULT {
Some(String::new())
} else {
menu_profiles
.iter()
.find(|(_, n, _)| n == other)
.map(|(id, _, _)| id.clone())
};
if let Some(id) = profile_id {
let mut target = target.clone();
target.profile = Some(id);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
}
})
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let pinned_base = target.clone();
tiles.push(host_tile(
&k.fp_hex,
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row_with(
&k.os,
Some(online),
// Paired is the resting state — no chip; TOFU-only trust is worth one.
(!k.paired).then_some(("Trusted", Pill::Info)),
// The dot carries the profile's own colour where it has one —
// that is what makes two bound hosts tell apart at a glance.
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _, _)| pid == id))
.map(|(_, name, accent)| (name.as_str(), accent.clone())),
),
Some(menu),
Some(Box::new(move || {
// Saved host with a known MAC that isn't advertising: fire a wake packet and
// DIAL IMMEDIATELY — mDNS absence ≠ unreachable (a routed/Tailscale host never
// advertises here); only a failed dial falls into the "Waking…" wait. An
// online host dials straight away.
if can_wake {
initiate_waking(&ctx2, target.clone(), &ss, &st);
} else {
initiate(&ctx2, target.clone(), &ss, &st);
}
})),
));
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
continue;
};
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
&hover,
&k.name,
&format!("{}:{}", k.addr, k.port),
status_row_with(
&k.os,
Some(online),
(!k.paired).then_some(("Trusted", Pill::Info)),
Some((name.as_str(), accent.clone())),
),
None,
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
} else {
initiate(&ctx3, pinned_target.clone(), &ss3, &st3);
}
})),
));
}
}
body.push(tile_grid(tiles, cols, TILE_GAP));
}
// 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(),
profile: None,
launch: None,
};
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(&h.os, None, Some((badge, kind))),
None,
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
));
}
body.push(tile_grid(tiles, cols, TILE_GAP));
}
// Forget confirmation, armed while `forget` holds a pending host. ALWAYS MOUNTED with
// `is_open` doing the arming, and in a STABLE trailing layer rather than in `body`
// (whose child list shifts with discovery): unmounting — or positionally re-pairing —
// a ContentDialog trips the reactor backend's phantom-child bookkeeping (the handle
// dies before `remove_child` runs, the dialog stops being recognised as phantom, and a
// visual child that never existed gets RemoveAt()'d — E_BOUNDS panic; see the
// delete-profile dialog in settings.rs). Confirmed first, since it's destructive and
// re-establishing trust needs a fresh pairing.
let forget_confirm: Element = {
let sf = set_forget.clone();
let pending = forget.clone();
let content = pending
.as_ref()
.map(|(_, name)| {
format!(
"Forget \u{201C}{name}\u{201D}? You'll need to pair (or trust) it again to \
reconnect."
)
})
.unwrap_or_default();
ContentDialog::new("Remove saved host?")
.content(content)
.primary_button_text("Remove")
.close_button_text("Cancel")
.is_open(pending.is_some())
.on_closed(move |r: ContentDialogResult| {
if r == ContentDialogResult::Primary {
if let Some((fp, _)) = &pending {
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);
// "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, live, sa) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual_live.clone(),
set_show_add.clone(),
);
move || {
let text = live.borrow();
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(),
profile: None,
launch: None,
},
&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({
let live = manual_live.clone();
move |s: String| {
live.set(s.clone());
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. Its layer slot is STABLE (a same-kind,
// background-less Border when closed — invisible and not hit-testable) so the layer
// list never changes shape around the always-mounted dialog after it. A tap on the
// scrim, or Escape, cancels — with the same bubble-swallow flag the sheets use.
let add_slot: Element = if show_add {
let inside_tap = std::rc::Rc::new(std::cell::Cell::new(false));
let cancel = {
let sa = set_show_add.clone();
move || sa.call(false)
};
let scrim_cancel = cancel.clone();
Element::from(
border(Element::from(modal).on_tapped({
let inside_tap = inside_tap.clone();
move || inside_tap.set(true)
}))
.background(Color {
a: (140.0 * props.add_anim) as u8,
r: 0,
g: 0,
b: 0,
})
.on_tapped(move || {
if inside_tap.replace(false) {
return;
}
scrim_cancel();
}),
)
.keyboard_accelerator(KeyboardAccelerator::new(
VirtualKey::Escape,
VirtualKeyModifiers::None,
cancel,
))
} else {
border(vstack(Vec::<Element>::new())).into()
};
// The host editor sheet, in its own stable slot (see the add modal's note).
let edit_slot: Element = if let Some((fp, initial)) = &rename {
edit_editor(
fp,
initial,
name_draft.clone(),
addr_draft.clone(),
port_draft.clone(),
mac_draft.clone(),
clip_draft.clone(),
set_rename.clone(),
)
} else {
border(vstack(Vec::<Element>::new())).into()
};
grid(vec![page, add_slot, edit_slot, forget_confirm]).into()
}