feat(linux): game library browser; split app.rs into cli/launch/ui_trust

- library.rs + ui_library.rs: the host's unified game library over the
  management API (the Apple LibraryClient/LibraryView ported) — mTLS with the
  paired identity, host verified by its pinned cert fingerprint (ureq + rustls,
  unified with the workspace rustls 0.23); posters load async with monogram
  placeholders, and picking a title starts a session that asks the host to
  launch it (the library id rides the Hello).
- app.rs (~800 lines lighter) splits into cli.rs (argv/headless
  pairing/--connect/screenshot scenes), launch.rs (mode resolve + session
  worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN /
  delegated-approval dialogs); ui_hosts/ui_stream reworked around the split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:04:43 +02:00
parent bd4e15b68d
commit e925d00194
20 changed files with 3591 additions and 1524 deletions
+619 -240
View File
@@ -1,9 +1,14 @@
//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
//! The hosts page: adaptive card grids for saved (trusted/paired) and mDNS-discovered
//! hosts, matching the other clients' look — avatar + name + `addr:port` + status pills,
//! online pips on saved cards, dashed discovered cards, an overflow menu, an add-host
//! dialog, and a connect-failure banner. Both grids re-render from one state snapshot
//! (known hosts on disk + the live advert map), so dedup and the online pips stay
//! consistent on every change.
use crate::discovery::{self, DiscoveredHost};
use crate::trust::KnownHosts;
use crate::discovery::{self, DiscoveredHost, DiscoveryEvent};
use crate::trust::{KnownHost, KnownHosts, Settings};
use adw::prelude::*;
use gtk::glib;
use gtk::{gio, glib};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
@@ -20,252 +25,158 @@ pub struct ConnectRequest {
pub port: u16,
pub fp_hex: Option<String>,
pub pair_optional: bool,
/// A library title to launch on connect (`(library id, display name)`, e.g.
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
pub launch: Option<(String, String)>,
}
pub fn new(
on_connect: Rc<dyn Fn(ConnectRequest)>,
on_settings: Rc<dyn Fn()>,
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
) -> adw::NavigationPage {
let list = gtk::ListBox::new();
list.add_css_class("boxed-list");
list.set_selection_mode(gtk::SelectionMode::None);
let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…"));
placeholder.add_css_class("dim-label");
placeholder.set_margin_top(24);
placeholder.set_margin_bottom(24);
list.set_placeholder(Some(&placeholder));
impl ConnectRequest {
/// The key the hosts page tracks an in-flight connect under (the card that swaps its
/// avatar for a spinner): the fingerprint when known, else the address.
pub fn card_key(&self) -> String {
self.fp_hex
.clone()
.unwrap_or_else(|| format!("{}:{}", self.addr, self.port))
}
}
// key → (row, latest advert); the activation closure looks the advert up by key so
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
/// The actions the page hands off to the app shell (trust gate, speed test, PIN pairing,
/// the library browser).
pub struct HostsCallbacks {
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
pub on_speed_test: Rc<dyn Fn(ConnectRequest)>,
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
pub on_library: Rc<dyn Fn(ConnectRequest)>,
}
{
let rx = discovery::browse();
let rows = rows.clone();
let list = list.downgrade();
let on_connect = on_connect.clone();
glib::spawn_future_local(async move {
while let Ok(host) = rx.recv().await {
let Some(list) = list.upgrade() else { break };
let mut map = rows.borrow_mut();
let subtitle = format!(
"{}:{} · pairing {}",
host.addr,
host.port,
if host.pair.is_empty() {
"optional"
} else {
&host.pair
}
);
if let Some((row, stored)) = map.get_mut(&host.key) {
row.set_title(&host.name);
row.set_subtitle(&subtitle);
*stored = host;
} else {
let row = adw::ActionRow::builder()
.title(&host.name)
.subtitle(&subtitle)
.activatable(true)
.build();
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let rows = rows.clone();
let key = host.key.clone();
let on_connect = on_connect.clone();
row.connect_activated(move |_| {
if let Some((_, h)) = rows.borrow().get(&key) {
on_connect(ConnectRequest {
name: h.name.clone(),
addr: h.addr.clone(),
port: h.port,
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
// TOFU is offered only when the host explicitly opts in
// with pair=optional; required/empty means mandatory PIN.
pair_optional: h.pair == "optional",
});
}
});
}
list.append(&row);
map.insert(host.key.clone(), (row, host));
}
}
});
/// The page plus the handle the launch path drives: connect-failure banner and the
/// per-card connecting spinner. Held by the `App` (`app.hosts_ui()`).
pub struct HostsUi {
pub page: adw::NavigationPage,
state: Rc<State>,
}
impl HostsUi {
/// Surface a connect failure at the top of the page (dismissible; replaces raw-error toasts).
pub fn show_error(&self, msg: &str) {
self.state.banner.set_title(msg);
self.state.banner.set_revealed(true);
}
// Manual connect: host:port (punktfunk/1 default port 9777).
let manual = adw::EntryRow::builder().title("host:port").build();
let connect_btn = gtk::Button::with_label("Connect");
connect_btn.set_valign(gtk::Align::Center);
connect_btn.add_css_class("suggested-action");
manual.add_suffix(&connect_btn);
let submit = {
let manual = manual.clone();
let on_connect = on_connect.clone();
move || {
let text = manual.text().to_string();
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => match p.parse::<u16>() {
Ok(port) => (a.to_string(), port),
Err(_) => return,
},
None => (text.to_string(), 9777),
};
on_connect(ConnectRequest {
name: addr.clone(),
addr,
port,
fp_hex: None,
// Manual entry carries no advertised policy — never eligible for TOFU.
pair_optional: false,
});
}
};
{
let submit = submit.clone();
connect_btn.connect_clicked(move |_| submit());
pub fn clear_error(&self) {
self.state.banner.set_revealed(false);
}
manual.connect_entry_activated(move |_| submit());
let manual_list = gtk::ListBox::new();
manual_list.add_css_class("boxed-list");
manual_list.set_selection_mode(gtk::SelectionMode::None);
manual_list.append(&manual);
/// Mark the card matching `key` (see `ConnectRequest::card_key`) as connecting —
/// spinner in place of the avatar, card insensitive. `None` restores all cards.
pub fn set_connecting(&self, key: Option<String>) {
*self.state.connecting.borrow_mut() = key;
rebuild(&self.state);
}
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
// the page is shown, so fresh TOFU/pairing entries appear on return.
let saved_label = gtk::Label::new(Some("Saved hosts"));
saved_label.add_css_class("heading");
saved_label.set_halign(gtk::Align::Start);
let saved_list = gtk::ListBox::new();
saved_list.add_css_class("boxed-list");
saved_list.set_selection_mode(gtk::SelectionMode::None);
let rebuild_saved = {
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let on_connect = on_connect.clone();
let on_speed_test = on_speed_test.clone();
move || {
saved_list.remove_all();
let known = KnownHosts::load();
saved_label.set_visible(!known.hosts.is_empty());
saved_list.set_visible(!known.hosts.is_empty());
for k in &known.hosts {
let row = adw::ActionRow::builder()
.title(&k.name)
.subtitle(format!(
"{}:{}{}",
k.addr,
k.port,
if k.paired {
" · paired"
} else {
" · trusted"
}
))
.activatable(true)
.build();
let req = ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
// Saved host: its fp is already pinned, so this routes to a silent
// pinned connect; TOFU eligibility is irrelevant.
pair_optional: false,
};
// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
// Confirmed first, since it's destructive and a misclick on the Deck is easy.
let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic");
remove_btn.set_tooltip_text(Some("Remove saved host"));
remove_btn.set_valign(gtk::Align::Center);
remove_btn.add_css_class("flat");
{
let fp = k.fp_hex.clone();
let name = k.name.clone();
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let row = row.clone();
remove_btn.connect_clicked(move |_| {
let dialog = adw::AlertDialog::new(
Some("Remove saved host?"),
Some(&format!(
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
)),
);
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
dialog.set_response_appearance(
"remove",
adw::ResponseAppearance::Destructive,
);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
{
// Scoped clones for the response handler so `row` survives for present().
let fp = fp.clone();
let saved_list = saved_list.clone();
let saved_label = saved_label.clone();
let row = row.clone();
dialog.connect_response(Some("remove"), move |_, _| {
let mut known = KnownHosts::load();
known.remove_by_fp(&fp);
let _ = known.save();
saved_list.remove(&row);
let empty = known.hosts.is_empty();
saved_list.set_visible(!empty);
saved_label.set_visible(!empty);
});
}
dialog.present(Some(&row));
});
}
row.add_suffix(&remove_btn);
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
speed_btn.set_tooltip_text(Some("Test network speed"));
speed_btn.set_valign(gtk::Align::Center);
speed_btn.add_css_class("flat");
{
let on_speed_test = on_speed_test.clone();
let req = req.clone();
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
}
row.add_suffix(&speed_btn);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let on_connect = on_connect.clone();
row.connect_activated(move |_| on_connect(req.clone()));
saved_list.append(&row);
}
}
/// Feed one advert through the same path the mDNS stream uses (CI screenshot scenes).
pub fn inject_advert(&self, host: DiscoveredHost) {
self.state
.adverts
.borrow_mut()
.insert(host.key.clone(), host);
rebuild(&self.state);
}
/// The "+" add-host dialog (name optional / address / port), also reachable from the
/// empty state. Reuses the manual-connect plumbing: submit runs the trust gate.
pub fn show_add_host(&self) {
add_host_dialog(&self.state);
}
/// Re-render both grids (e.g. the library toggle changed in Preferences, which adds/
/// removes the saved cards' "Browse library…" menu item).
pub fn refresh(&self) {
rebuild(&self.state);
}
/// The advertised mgmt port for the host `req` points at, when a matching live advert
/// carries the `mgmt` TXT — the library client's port override (default otherwise).
pub fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
let adverts = self.state.adverts.borrow();
adverts
.values()
.find(|a| {
req.fp_hex
.as_deref()
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|| (a.addr == req.addr && a.port == req.port)
})
.and_then(|a| a.mgmt_port)
}
}
/// Everything the grids re-render from, plus the widgets they render into.
struct State {
stack: gtk::Stack,
banner: adw::Banner,
saved_heading: gtk::Label,
saved_flow: gtk::FlowBox,
disc_flow: gtk::FlowBox,
searching: gtk::Box,
/// Live mDNS adverts, keyed by the advert key — the source for the discovered grid,
/// the saved cards' online pips, and dedup.
adverts: RefCell<HashMap<String, DiscoveredHost>>,
/// `card_key` of the connect currently in flight, if any.
connecting: RefCell<Option<String>>,
/// App settings — read on every rebuild for the experimental library-item gate.
settings: Rc<RefCell<Settings>>,
cbs: HostsCallbacks,
}
pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
let make_flow = || {
gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.homogeneous(true)
.min_children_per_line(1)
.max_children_per_line(4)
.column_spacing(12)
.row_spacing(12)
.build()
};
rebuild_saved();
let heading = |text: &str| {
let l = gtk::Label::new(Some(text));
l.add_css_class("heading");
l.set_halign(gtk::Align::Start);
l
};
let saved_heading = heading("Saved hosts");
let saved_flow = make_flow();
let disc_heading = heading("On this network");
let disc_flow = make_flow();
let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
// Shown under the discovered heading while no (unsaved) advert is live yet.
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let spinner = gtk::Spinner::new();
spinner.start();
searching.append(&spinner);
let searching_label = gtk::Label::new(Some("Searching the LAN…"));
searching_label.add_css_class("dim-label");
searching.append(&searching_label);
searching.set_margin_top(6);
searching.set_margin_bottom(6);
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&saved_label);
content.append(&saved_list);
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
discovered_label.add_css_class("heading");
discovered_label.set_halign(gtk::Align::Start);
content.append(&discovered_label);
content.append(&list);
let manual_label = gtk::Label::new(Some("Manual connection"));
manual_label.add_css_class("heading");
manual_label.set_halign(gtk::Align::Start);
content.append(&manual_label);
content.append(&manual_list);
content.append(&saved_heading);
content.append(&saved_flow);
content.append(&disc_heading);
content.append(&searching);
content.append(&disc_flow);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.maximum_size(1100)
.child(&content)
.build();
let scrolled = gtk::ScrolledWindow::builder()
@@ -273,21 +184,489 @@ pub fn new(
.child(&clamp)
.build();
// No saved hosts AND nothing on the LAN → the whole page is the empty state.
let empty = adw::StatusPage::builder()
.icon_name("network-workgroup-symbolic")
.title("No hosts yet")
.description("Hosts on your network appear here automatically.\nAdd one by address with +.")
.build();
let add_btn = gtk::Button::with_label("Add host");
add_btn.add_css_class("pill");
add_btn.add_css_class("suggested-action");
add_btn.set_halign(gtk::Align::Center);
add_btn.set_action_name(Some("win.add-host"));
empty.set_child(Some(&add_btn));
let stack = gtk::Stack::new();
stack.add_named(&scrolled, Some("grid"));
stack.add_named(&empty, Some("empty"));
// Connect failures land here (launch.rs routes on_failed/on_ended), not in toasts.
let banner = adw::Banner::new("");
banner.set_button_label(Some("Dismiss"));
banner.connect_button_clicked(|b| b.set_revealed(false));
let header = adw::HeaderBar::new();
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
settings_btn.set_tooltip_text(Some("Preferences"));
settings_btn.connect_clicked(move |_| on_settings());
header.pack_end(&settings_btn);
let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic");
add_host_btn.set_tooltip_text(Some("Add host"));
add_host_btn.set_action_name(Some("win.add-host"));
header.pack_start(&add_host_btn);
// Primary menu — the actions live on the window (installed in app.rs).
let menu = gio::Menu::new();
menu.append(Some("Preferences"), Some("win.preferences"));
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Punktfunk"), Some("win.about"));
let menu_btn = gtk::MenuButton::builder()
.icon_name("open-menu-symbolic")
.menu_model(&menu)
.primary(true)
.tooltip_text("Main menu")
.build();
header.pack_end(&menu_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
toolbar.add_top_bar(&banner);
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title("Punktfunk")
.tag("hosts")
.child(&toolbar)
.build();
page.connect_shown(move |_| rebuild_saved());
page
let state = Rc::new(State {
stack,
banner,
saved_heading,
saved_flow,
disc_flow,
searching,
adverts: RefCell::new(HashMap::new()),
connecting: RefCell::new(None),
settings,
cbs,
});
rebuild(&state);
// Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on return.
{
let state = state.clone();
page.connect_shown(move |_| rebuild(&state));
}
// Stream mDNS adverts into the map; every add/remove re-evaluates both grids (online
// pips + dedup included).
{
let rx = discovery::browse();
let weak = Rc::downgrade(&state);
glib::spawn_future_local(async move {
while let Ok(event) = rx.recv().await {
let Some(state) = weak.upgrade() else { break };
match event {
DiscoveryEvent::Resolved(h) => {
state.adverts.borrow_mut().insert(h.key.clone(), h);
}
DiscoveryEvent::Removed { fullname } => {
state
.adverts
.borrow_mut()
.retain(|_, a| a.fullname != fullname);
}
}
rebuild(&state);
}
});
}
HostsUi { page, state }
}
/// Re-render both grids from disk + the advert map. Cheap (a handful of widgets) and
/// keeps every derived view — online pips, dedup, most-recent accent, spinner — in one
/// straight-line pass instead of incremental row surgery.
fn rebuild(state: &Rc<State>) {
let known = KnownHosts::load();
let adverts = state.adverts.borrow();
let connecting = state.connecting.borrow().clone();
// A saved host is ONLINE iff a live advert matches it (fingerprint, or address when
// the advert carries no fp) — same rule the Apple client uses.
let matches = |k: &KnownHost, a: &DiscoveredHost| {
(!a.fp_hex.is_empty() && a.fp_hex == k.fp_hex) || (a.addr == k.addr && a.port == k.port)
};
let most_recent = known
.hosts
.iter()
.filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t)))
.max_by_key(|&(_, t)| t)
.map(|(fp, _)| fp);
state.saved_flow.remove_all();
for k in &known.hosts {
let online = adverts.values().any(|a| matches(k, a));
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
state
.saved_flow
.append(&saved_card(state, k, online, recent, connecting.as_deref()));
}
// The discovered grid only surfaces genuinely-new hosts: anything matching a saved
// entry renders as that saved card (with its pip now green) instead.
let mut fresh: Vec<&DiscoveredHost> = adverts
.values()
.filter(|a| !known.hosts.iter().any(|k| matches(k, a)))
.collect();
fresh.sort_by(|a, b| a.name.cmp(&b.name).then(a.key.cmp(&b.key)));
state.disc_flow.remove_all();
for a in &fresh {
state
.disc_flow
.append(&discovered_card(state, a, connecting.as_deref()));
}
let have_saved = !known.hosts.is_empty();
let have_disc = !fresh.is_empty();
state.saved_heading.set_visible(have_saved);
state.saved_flow.set_visible(have_saved);
state.disc_flow.set_visible(have_disc);
state.searching.set_visible(!have_disc);
state
.stack
.set_visible_child_name(if have_saved || have_disc {
"grid"
} else {
"empty"
});
}
/// The shared card scaffold: avatar (or a spinner while connecting) over name over
/// `addr:port` over a status row, in a `.card` overlay (the overlay hosts the saved
/// card's corner menu). Returned as the FlowBox child so callers wire activation on it.
fn card_scaffold(
name: &str,
addr_line: &str,
status_row: &gtk::Box,
connecting: bool,
) -> (gtk::FlowBoxChild, gtk::Overlay) {
let content = gtk::Box::new(gtk::Orientation::Vertical, 6);
if connecting {
let spinner = gtk::Spinner::new();
spinner.set_size_request(48, 48);
spinner.start();
spinner.set_halign(gtk::Align::Center);
content.append(&spinner);
} else {
let avatar = adw::Avatar::new(48, Some(name), true);
avatar.set_halign(gtk::Align::Center);
content.append(&avatar);
}
let name_label = gtk::Label::new(Some(name));
name_label.add_css_class("heading");
name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
content.append(&name_label);
let addr_label = gtk::Label::new(Some(addr_line));
addr_label.add_css_class("caption");
addr_label.add_css_class("dim-label");
addr_label.add_css_class("numeric");
addr_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
content.append(&addr_label);
status_row.set_halign(gtk::Align::Center);
status_row.set_margin_top(4);
content.append(status_row);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&content));
overlay.add_css_class("card");
overlay.add_css_class("pf-host-card");
let child = gtk::FlowBoxChild::new();
child.set_child(Some(&overlay));
if connecting {
child.set_sensitive(false);
}
(child, overlay)
}
/// A small rounded status chip (`.pf-pill` + a colour variant class).
fn pill(text: &str, class: &str) -> gtk::Label {
let l = gtk::Label::new(Some(text));
l.add_css_class("pf-pill");
l.add_css_class(class);
l
}
fn saved_card(
state: &Rc<State>,
k: &KnownHost,
online: bool,
recent: bool,
connecting: Option<&str>,
) -> gtk::FlowBoxChild {
let req = ConnectRequest {
name: k.name.clone(),
addr: k.addr.clone(),
port: k.port,
fp_hex: Some(k.fp_hex.clone()),
// Saved host: its fp is already pinned, so this routes to a silent pinned
// connect; TOFU eligibility is irrelevant.
pair_optional: false,
launch: None,
};
// Presence pip + spelled-out state, then the trust pill.
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
pip.add_css_class("pf-pip");
if online {
pip.add_css_class("pf-online");
}
pip.set_valign(gtk::Align::Center);
status.append(&pip);
let presence = gtk::Label::new(Some(if online { "Online" } else { "Offline" }));
presence.add_css_class("caption");
presence.add_css_class("dim-label");
status.append(&presence);
status.append(&if k.paired {
pill("Paired", "pf-green")
} else {
pill("Trusted", "pf-accent")
});
let (child, overlay) = card_scaffold(
&k.name,
&format!("{}:{}", k.addr, k.port),
&status,
connecting == Some(k.fp_hex.as_str()),
);
if recent {
overlay.add_css_class("pf-recent");
}
// Overflow menu (top-right; also on right-click): pair / speed test / rename / forget.
let actions = gio::SimpleActionGroup::new();
let add = |name: &str, f: Box<dyn Fn()>| {
let a = gio::SimpleAction::new(name, None);
a.connect_activate(move |_, _| f());
actions.add_action(&a);
};
{
let cb = state.cbs.on_pair.clone();
let req = req.clone();
add("pair", Box::new(move || cb(req.clone())));
}
{
let cb = state.cbs.on_speed_test.clone();
let req = req.clone();
add("speed", Box::new(move || cb(req.clone())));
}
{
let cb = state.cbs.on_library.clone();
let req = req.clone();
add("library", Box::new(move || cb(req.clone())));
}
{
let state = state.clone();
let fp = k.fp_hex.clone();
let name = k.name.clone();
add(
"rename",
Box::new(move || rename_dialog(&state, &fp, &name)),
);
}
{
let state = state.clone();
let fp = k.fp_hex.clone();
let name = k.name.clone();
add(
"forget",
Box::new(move || forget_dialog(&state, &fp, &name)),
);
}
overlay.insert_action_group("card", Some(&actions));
let menu = gio::Menu::new();
menu.append(Some("Pair with PIN…"), Some("card.pair"));
menu.append(Some("Test network speed…"), Some("card.speed"));
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
// item is offered on every saved card — an unpaired host answers with the friendly
// "not paired" error state rather than the entry hiding itself.
if state.settings.borrow().library_enabled {
menu.append(Some("Browse library…"), Some("card.library"));
}
menu.append(Some("Rename…"), Some("card.rename"));
menu.append(Some("Forget"), Some("card.forget"));
let menu_btn = gtk::MenuButton::builder()
.icon_name("view-more-symbolic")
.menu_model(&menu)
.halign(gtk::Align::End)
.valign(gtk::Align::Start)
.build();
menu_btn.add_css_class("flat");
overlay.add_overlay(&menu_btn);
let right_click = gtk::GestureClick::builder().button(3).build();
{
let menu_btn = menu_btn.clone();
right_click.connect_pressed(move |_, _, _, _| menu_btn.popup());
}
overlay.add_controller(right_click);
let on_connect = state.cbs.on_connect.clone();
child.connect_activate(move |_| on_connect(req.clone()));
child
}
fn discovered_card(
state: &Rc<State>,
a: &DiscoveredHost,
connecting: Option<&str>,
) -> gtk::FlowBoxChild {
let req = ConnectRequest {
name: a.name.clone(),
addr: a.addr.clone(),
port: a.port,
fp_hex: (!a.fp_hex.is_empty()).then(|| a.fp_hex.clone()),
// TOFU is offered only when the host explicitly opts in with pair=optional;
// required/empty means mandatory PIN.
pair_optional: a.pair == "optional",
launch: None,
};
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
status.append(&if req.pair_optional {
pill("Open", "pf-neutral")
} else {
pill("PIN", "pf-accent")
});
let is_connecting = connecting == Some(req.card_key().as_str());
let (child, overlay) = card_scaffold(
&a.name,
&format!("{}:{}", a.addr, a.port),
&status,
is_connecting,
);
overlay.add_css_class("pf-discovered");
// Tap-to-connect only (parity with Android's discovered cards).
let on_connect = state.cbs.on_connect.clone();
child.connect_activate(move |_| on_connect(req.clone()));
child
}
/// Rename a saved host — an entry in an alert, then upsert + refresh.
fn rename_dialog(state: &Rc<State>, fp_hex: &str, current: &str) {
let entry = gtk::Entry::builder()
.text(current)
.activates_default(true)
.build();
let dialog = adw::AlertDialog::new(Some("Rename Host"), None);
dialog.set_extra_child(Some(&entry));
dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]);
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
{
let state = state.clone();
let fp = fp_hex.to_string();
dialog.connect_response(Some("rename"), move |_, _| {
let name = entry.text().trim().to_string();
if name.is_empty() {
return;
}
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.name = name;
let _ = known.save();
}
rebuild(&state);
});
}
dialog.present(Some(&state.stack));
}
/// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
/// Confirmed first, since it's destructive and a misclick on the Deck is easy.
fn forget_dialog(state: &Rc<State>, fp_hex: &str, name: &str) {
let dialog = adw::AlertDialog::new(
Some("Remove saved host?"),
Some(&format!(
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
)),
);
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
{
let state = state.clone();
let fp = fp_hex.to_string();
dialog.connect_response(Some("remove"), move |_, _| {
let mut known = KnownHosts::load();
known.remove_by_fp(&fp);
let _ = known.save();
rebuild(&state);
});
}
dialog.present(Some(&state.stack));
}
/// "+": name (optional) / address / port — the Apple AddHostSheet / Android dialog
/// equivalent of the old inline entry. Submit runs the normal trust gate (`on_connect`).
fn add_host_dialog(state: &Rc<State>) {
let list = gtk::ListBox::new();
list.add_css_class("boxed-list");
list.set_selection_mode(gtk::SelectionMode::None);
let name_row = adw::EntryRow::builder().title("Name (optional)").build();
let addr_row = adw::EntryRow::builder().title("Address").build();
let port_row = adw::EntryRow::builder().title("Port").text("9777").build();
list.append(&name_row);
list.append(&addr_row);
list.append(&port_row);
list.set_size_request(320, -1);
let dialog = adw::AlertDialog::new(Some("Add Host"), None);
dialog.set_extra_child(Some(&list));
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("connect"));
dialog.set_close_response("cancel");
dialog.set_response_enabled("connect", false);
{
let dialog = dialog.clone();
addr_row.connect_changed(move |row| {
dialog.set_response_enabled("connect", !row.text().trim().is_empty());
});
}
{
let on_connect = state.cbs.on_connect.clone();
let (name_row, addr_row, port_row) = (name_row.clone(), addr_row.clone(), port_row.clone());
dialog.connect_response(Some("connect"), move |_, _| {
let text = addr_row.text().trim().to_string();
if text.is_empty() {
return;
}
// A pasted `host:port` wins over the port field; otherwise the field (default 9777).
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) if p.parse::<u16>().is_ok() => {
(a.to_string(), p.parse::<u16>().unwrap())
}
_ => (
text.clone(),
port_row.text().trim().parse::<u16>().unwrap_or(9777),
),
};
let name = name_row.text().trim().to_string();
on_connect(ConnectRequest {
name: if name.is_empty() { addr.clone() } else { name },
addr,
port,
fp_hex: None,
// Manual entry carries no advertised policy — never eligible for TOFU.
pair_optional: false,
launch: None,
});
});
}
dialog.present(Some(&state.stack));
}