feat(linux-client): WOL wait-until-up + IP re-key (Apple/Android parity)
The Linux client already had WOL send + MAC storage + a Wake action + auto-wake- on-connect, but the auto-wake just fired a packet and did one dial to the stored address — so a host that woke on a new DHCP lease failed, and there was no "waiting" feedback. Add the polished flow (mirrors Apple/Android HostWaker): - ui_trust::wake_and_connect — send the magic packet, poll mDNS until the host reappears (re-sending every 6 s, 90 s budget) behind a cancelable "Waking…" dialog, then connect; if it woke on a new IP, re-key the saved host first. - trust::rekey_addr — no-churn addr/port update keyed by fingerprint. - the hosts page routes an offline saved-host-with-MAC tap to on_wake_connect (the new flow) instead of fire-and-forget wake + immediate dial. Builds + clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -242,6 +242,10 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
||||
},
|
||||
on_wake_connect: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_trust::wake_and_connect(app.clone(), req))
|
||||
},
|
||||
on_speed_test: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| speed_test(app.clone(), req))
|
||||
|
||||
@@ -168,6 +168,26 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
|
||||
/// fingerprint). No-op — and no disk write — when unchanged. Called from the wake-and-wait flow when
|
||||
/// a woken host reappears on a different IP than the stored one, so this and future connects dial the
|
||||
/// live address instead of the stale one.
|
||||
pub fn rekey_addr(fp_hex: &str, addr: &str, port: u16) {
|
||||
if fp_hex.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
|
||||
return;
|
||||
};
|
||||
if h.addr == addr && h.port == port {
|
||||
return;
|
||||
}
|
||||
h.addr = addr.to_string();
|
||||
h.port = port;
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
||||
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||
pub fn touch_last_used(fp_hex: &str) {
|
||||
|
||||
@@ -48,6 +48,9 @@ impl ConnectRequest {
|
||||
/// the library browser).
|
||||
pub struct HostsCallbacks {
|
||||
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||
/// Connect to an OFFLINE saved host with a known MAC: wake it, poll until it's up (re-keying a
|
||||
/// new DHCP IP), then connect. Falls back to `on_connect` when there's nothing to wake.
|
||||
pub on_wake_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)>,
|
||||
@@ -546,15 +549,17 @@ fn saved_card(
|
||||
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 on_wake_connect = state.cbs.on_wake_connect.clone();
|
||||
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, route to
|
||||
// the wake-and-wait flow (send a magic packet, poll mDNS until it's up — re-keying a new DHCP IP —
|
||||
// then connect). Otherwise a plain connect. A host that's genuinely off then times out 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_wake_connect(req.clone());
|
||||
} else {
|
||||
on_connect(req.clone());
|
||||
}
|
||||
});
|
||||
child
|
||||
}
|
||||
|
||||
@@ -60,6 +60,87 @@ pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
|
||||
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
|
||||
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
|
||||
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
|
||||
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
|
||||
/// only from the hosts page's auto-wake when a saved host isn't advertising.
|
||||
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.get() {
|
||||
return;
|
||||
}
|
||||
let cancel = 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(&app.window));
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::{Duration, Instant};
|
||||
let events = crate::discovery::browse();
|
||||
let started = Instant::now();
|
||||
let budget = Duration::from_secs(90);
|
||||
let resend = Duration::from_secs(6);
|
||||
// Fire the first packet now, then re-send on the resend cadence.
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
let mut last_wake = Instant::now();
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
return;
|
||||
}
|
||||
if last_wake.elapsed() >= resend {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
last_wake = Instant::now();
|
||||
}
|
||||
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
|
||||
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 {
|
||||
waiting.close();
|
||||
let mut req = req.clone();
|
||||
// Re-key on a new DHCP lease so this + future connects dial the live address.
|
||||
if h.addr != req.addr || h.port != req.port {
|
||||
if let Some(fp) = &req.fp_hex {
|
||||
trust::rekey_addr(fp, &h.addr, h.port);
|
||||
}
|
||||
req.addr = h.addr;
|
||||
req.port = h.port;
|
||||
}
|
||||
initiate_connect(app.clone(), req);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if started.elapsed() >= budget {
|
||||
waiting.close();
|
||||
app.toast(&format!(
|
||||
"Couldn't reach “{}” — is it powered and on the network?",
|
||||
req.name
|
||||
));
|
||||
return;
|
||||
}
|
||||
glib::timeout_future(Duration::from_millis(500)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
||||
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
||||
/// one 64-char run.
|
||||
|
||||
Reference in New Issue
Block a user