7e6561aaa2
ci / rust (push) Failing after 51s
ci / web (push) Successful in 53s
windows-host / package (push) Failing after 2m54s
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m10s
android / android (push) Successful in 3m38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m21s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 41s
ci / bench (push) Successful in 4m48s
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 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
release / apple (push) Successful in 8m47s
deb / build-publish (push) Successful in 9m26s
flatpak / build-publish (push) Successful in 4m44s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
718 lines
26 KiB
Rust
718 lines
26 KiB
Rust
//! 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, DiscoveryEvent};
|
|
use crate::trust::{KnownHost, KnownHosts, Settings};
|
|
use adw::prelude::*;
|
|
use gtk::{gio, glib};
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::rc::Rc;
|
|
|
|
/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the
|
|
/// host was discovered (drives the trust decision *before* connecting); manual entries have
|
|
/// none. `pair_optional` is true ONLY when a discovered host advertised `pair=optional`,
|
|
/// which is the sole case in which the reduced-security TOFU path may be offered — every
|
|
/// other case (pair=required, unknown/empty policy, manual entry) mandates PIN pairing.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ConnectRequest {
|
|
pub name: String,
|
|
pub addr: String,
|
|
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)>,
|
|
/// 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 mac: Vec<String>,
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
/// 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)>,
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
pub fn clear_error(&self) {
|
|
self.state.banner.set_revealed(false);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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()
|
|
};
|
|
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();
|
|
|
|
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
|
|
// the child's own `activate` signal — so bridge it back to the child, where each card wires
|
|
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
|
|
for flow in [&saved_flow, &disc_flow] {
|
|
flow.connect_child_activated(|_, child| {
|
|
child.activate();
|
|
});
|
|
}
|
|
|
|
// 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_heading);
|
|
content.append(&saved_flow);
|
|
content.append(&disc_heading);
|
|
content.append(&searching);
|
|
content.append(&disc_flow);
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(1100)
|
|
.child(&content)
|
|
.build();
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.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 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.add_top_bar(&banner);
|
|
toolbar.set_content(Some(&stack));
|
|
|
|
let page = adw::NavigationPage::builder()
|
|
.title("Punktfunk")
|
|
.tag("hosts")
|
|
.child(&toolbar)
|
|
.build();
|
|
|
|
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));
|
|
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it
|
|
// once it sleeps and stops advertising (no-op / no disk write when unchanged).
|
|
if let Some(a) = adverts
|
|
.values()
|
|
.find(|a| matches(k, a) && !a.mac.is_empty())
|
|
{
|
|
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
|
}
|
|
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: >k::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,
|
|
mac: k.mac.clone(),
|
|
};
|
|
|
|
// 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)),
|
|
);
|
|
}
|
|
{
|
|
// Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host).
|
|
let mac = k.mac.clone();
|
|
let addr = k.addr.clone();
|
|
add(
|
|
"wake",
|
|
Box::new(move || crate::wol::wake(&mac, addr.parse().ok())),
|
|
);
|
|
}
|
|
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"));
|
|
// Offer an explicit wake only when the host is offline and we actually have a MAC to target.
|
|
if !online && !k.mac.is_empty() {
|
|
menu.append(Some("Wake host"), Some("card.wake"));
|
|
}
|
|
// 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();
|
|
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
|
|
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
|
|
// come up. A host that's genuinely off/unreachable then fails the connect as before.
|
|
let wake_first = !online && !req.mac.is_empty();
|
|
child.connect_activate(move |_| {
|
|
if wake_first {
|
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
|
}
|
|
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,
|
|
mac: a.mac.clone(),
|
|
};
|
|
|
|
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,
|
|
mac: Vec::new(),
|
|
});
|
|
});
|
|
}
|
|
dialog.present(Some(&state.stack));
|
|
}
|