ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
360 lines
14 KiB
Rust
360 lines
14 KiB
Rust
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
||
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
||
//! each resolving into typed [`AppMsg`]s.
|
||
|
||
use crate::app::{AppModel, AppMsg};
|
||
use crate::spawn::{CancelHandle, SpawnOpts};
|
||
use crate::trust;
|
||
use crate::ui_hosts::ConnectRequest;
|
||
use adw::prelude::*;
|
||
use gtk::glib;
|
||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||
use relm4::prelude::*;
|
||
|
||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||
/// lets the user cancel.
|
||
///
|
||
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
|
||
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
|
||
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
|
||
pub fn wake_and_connect(
|
||
window: &adw::ApplicationWindow,
|
||
sender: &ComponentSender<AppModel>,
|
||
req: ConnectRequest,
|
||
) {
|
||
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||
let waiting = adw::AlertDialog::new(
|
||
Some("Waking Host"),
|
||
Some(&format!(
|
||
"Sent a wake signal to “{}”. Waiting for it to come online…",
|
||
req.name
|
||
)),
|
||
);
|
||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||
waiting.set_close_response("cancel");
|
||
{
|
||
let cancel = cancel.clone();
|
||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||
}
|
||
waiting.present(Some(window));
|
||
|
||
let sender = sender.clone();
|
||
glib::spawn_future_local(async move {
|
||
use std::time::Duration;
|
||
let (events, rescan) = crate::discovery::browse();
|
||
let mut wait = WakeWait::new();
|
||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||
// again periodically instead of waiting to be told. Every 5th tick: often enough that a
|
||
// host that came up is noticed promptly, rare enough not to hammer multicast.
|
||
let mut ticks: u32 = 0;
|
||
loop {
|
||
if cancel.get() {
|
||
waiting.close();
|
||
return;
|
||
}
|
||
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
|
||
// and carries the address it came back on.
|
||
let mut seen: Option<(String, u16)> = None;
|
||
while let Ok(ev) = events.try_recv() {
|
||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||
continue;
|
||
};
|
||
let matched = match &req.fp_hex {
|
||
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
|
||
None => h.addr == req.addr && h.port == req.port,
|
||
};
|
||
if matched {
|
||
seen = Some((h.addr, h.port));
|
||
}
|
||
}
|
||
let tick = wait.tick(seen.is_some());
|
||
if tick.send_packet {
|
||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||
}
|
||
match tick.outcome {
|
||
Some(WakeOutcome::Online) => {
|
||
waiting.close();
|
||
let mut req = req.clone();
|
||
// Re-key on a new DHCP lease so this + future connects dial the
|
||
// live address.
|
||
if let Some((addr, port)) =
|
||
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
|
||
{
|
||
if let Some(fp) = &req.fp_hex {
|
||
trust::rekey_addr(fp, &addr, port);
|
||
}
|
||
req.addr = addr;
|
||
req.port = port;
|
||
}
|
||
sender.input(AppMsg::Connect(req));
|
||
return;
|
||
}
|
||
Some(WakeOutcome::TimedOut) => {
|
||
waiting.close();
|
||
sender.input(AppMsg::Toast(format!(
|
||
"Couldn't reach “{}” — is it powered and on the network?",
|
||
req.name
|
||
)));
|
||
return;
|
||
}
|
||
None => {}
|
||
}
|
||
ticks += 1;
|
||
if ticks % 5 == 0 {
|
||
rescan.request();
|
||
}
|
||
glib::timeout_future(Duration::from_secs(1)).await;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
|
||
/// far easier to compare against the host's log than one 64-char run.
|
||
fn grouped_fingerprint(fp: &str) -> String {
|
||
let groups: Vec<&str> = fp
|
||
.as_bytes()
|
||
.chunks(4)
|
||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||
.collect();
|
||
groups
|
||
.chunks(8)
|
||
.map(|line| line.join(" "))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
||
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
||
pub fn tofu_dialog(
|
||
window: &adw::ApplicationWindow,
|
||
sender: &ComponentSender<AppModel>,
|
||
req: ConnectRequest,
|
||
) {
|
||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||
let dialog = adw::AlertDialog::new(
|
||
Some("New Host"),
|
||
Some(&format!(
|
||
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
|
||
trusting accepts it as-is.",
|
||
req.name, req.addr, req.port
|
||
)),
|
||
);
|
||
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
|
||
fp_label.add_css_class("monospace");
|
||
fp_label.set_selectable(true);
|
||
fp_label.set_justify(gtk::Justification::Center);
|
||
fp_label.set_halign(gtk::Align::Center);
|
||
dialog.set_extra_child(Some(&fp_label));
|
||
dialog.add_responses(&[
|
||
("cancel", "Cancel"),
|
||
("pair", "Pair with PIN…"),
|
||
("trust", "Trust & Connect"),
|
||
]);
|
||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||
dialog.set_default_response(Some("trust"));
|
||
dialog.set_close_response("cancel");
|
||
let sender = sender.clone();
|
||
dialog.connect_response(None, move |_, response| match response {
|
||
"trust" => sender.input(AppMsg::StartSession {
|
||
req: req.clone(),
|
||
fp_hex: fp.clone(),
|
||
tofu: true,
|
||
opts: SpawnOpts::default(),
|
||
}),
|
||
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
||
_ => {}
|
||
});
|
||
dialog.present(Some(window));
|
||
}
|
||
|
||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||
/// transcript. Success persists the host as paired and connects.
|
||
pub fn pin_dialog(
|
||
window: &adw::ApplicationWindow,
|
||
sender: &ComponentSender<AppModel>,
|
||
identity: (String, String),
|
||
req: ConnectRequest,
|
||
) {
|
||
let entry = gtk::Entry::builder()
|
||
.input_purpose(gtk::InputPurpose::Digits)
|
||
.placeholder_text("4-digit PIN shown by the host")
|
||
.activates_default(true)
|
||
.build();
|
||
// The label the HOST stores this client under — prefilled with the hostname.
|
||
let name_entry = gtk::Entry::builder()
|
||
.text(glib::host_name().as_str())
|
||
.activates_default(true)
|
||
.build();
|
||
let name_caption = gtk::Label::new(Some("This device"));
|
||
name_caption.add_css_class("caption");
|
||
name_caption.add_css_class("dim-label");
|
||
name_caption.set_halign(gtk::Align::Start);
|
||
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||
fields.append(&name_caption);
|
||
fields.append(&name_entry);
|
||
let pin_caption = gtk::Label::new(Some("PIN"));
|
||
pin_caption.add_css_class("caption");
|
||
pin_caption.add_css_class("dim-label");
|
||
pin_caption.set_halign(gtk::Align::Start);
|
||
fields.append(&pin_caption);
|
||
fields.append(&entry);
|
||
let dialog = adw::AlertDialog::new(
|
||
Some("Pair with PIN"),
|
||
Some(&format!(
|
||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||
req.name
|
||
)),
|
||
);
|
||
dialog.set_extra_child(Some(&fields));
|
||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||
dialog.set_default_response(Some("pair"));
|
||
dialog.set_close_response("cancel");
|
||
let sender = sender.clone();
|
||
dialog.connect_response(Some("pair"), move |_, _| {
|
||
let pin = entry.text().to_string();
|
||
let req = req.clone();
|
||
let identity = identity.clone();
|
||
let sender = sender.clone();
|
||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||
let device = name_entry.text().trim().to_string();
|
||
let name = if device.is_empty() {
|
||
glib::host_name().to_string()
|
||
} else {
|
||
device
|
||
};
|
||
let (host, port) = (req.addr.clone(), req.port);
|
||
std::thread::spawn(move || {
|
||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
||
// rejection) — never blame the PIN for a dead network path.
|
||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||
.map_err(|e| trust::pair_error_message(&e));
|
||
let _ = tx.send_blocking(result);
|
||
});
|
||
glib::spawn_future_local(async move {
|
||
match rx.recv().await {
|
||
Ok(Ok(fp)) => {
|
||
let fp_hex = trust::hex(&fp);
|
||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
||
sender.input(AppMsg::StartSession {
|
||
req,
|
||
fp_hex,
|
||
tofu: false,
|
||
opts: SpawnOpts::default(),
|
||
});
|
||
}
|
||
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
||
Err(_) => {}
|
||
}
|
||
});
|
||
});
|
||
dialog.present(Some(window));
|
||
}
|
||
|
||
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||
/// ceremony.
|
||
pub fn approval_dialog(
|
||
window: &adw::ApplicationWindow,
|
||
sender: &ComponentSender<AppModel>,
|
||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||
req: ConnectRequest,
|
||
) {
|
||
let dialog = adw::AlertDialog::new(
|
||
Some("Pairing Required"),
|
||
Some(&format!(
|
||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||
req.name
|
||
)),
|
||
);
|
||
dialog.add_responses(&[
|
||
("cancel", "Cancel"),
|
||
("pin", "Use a PIN instead…"),
|
||
("request", "Request Access"),
|
||
]);
|
||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||
dialog.set_default_response(Some("request"));
|
||
dialog.set_close_response("cancel");
|
||
let parent = window.clone();
|
||
let window = window.clone();
|
||
let sender = sender.clone();
|
||
dialog.connect_response(None, move |_, response| match response {
|
||
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||
_ => {}
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||
///
|
||
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||
/// the session binary refuses pinless connects, so this path requires the advert; a
|
||
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
||
fn request_access(
|
||
window: &adw::ApplicationWindow,
|
||
sender: &ComponentSender<AppModel>,
|
||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||
req: ConnectRequest,
|
||
) {
|
||
let Some(fp_hex) = req.fp_hex.clone() else {
|
||
// No fingerprint to pin (manual entry): the strict child can't do a
|
||
// trust-on-approval connect — route to the PIN ceremony instead.
|
||
sender.input(AppMsg::Toast(
|
||
"No advertised identity for this host — pair with a PIN instead.".into(),
|
||
));
|
||
sender.input(AppMsg::Pair(req));
|
||
return;
|
||
};
|
||
let cancel = CancelHandle::default();
|
||
let waiting = adw::AlertDialog::new(
|
||
Some("Waiting for Approval"),
|
||
Some(&format!(
|
||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||
connects automatically once you approve it.",
|
||
glib::host_name(),
|
||
req.name
|
||
)),
|
||
);
|
||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||
waiting.set_close_response("cancel");
|
||
{
|
||
let sender = sender.clone();
|
||
let cancel = cancel.clone();
|
||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||
cancel.kill();
|
||
sender.input(AppMsg::CancelPending);
|
||
});
|
||
}
|
||
waiting.present(Some(window));
|
||
*waiting_slot.borrow_mut() = Some(waiting);
|
||
|
||
sender.input(AppMsg::StartSession {
|
||
req,
|
||
fp_hex,
|
||
tofu: false,
|
||
opts: SpawnOpts {
|
||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||
// operator approval still lands on this connection.
|
||
connect_timeout_secs: Some(185),
|
||
persist_paired: true,
|
||
cancel: Some(cancel),
|
||
},
|
||
});
|
||
}
|