diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index b8e5d4a..b63b6f6 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -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)) diff --git a/clients/linux/src/trust.rs b/clients/linux/src/trust.rs index 86739ac..a7b15c2 100644 --- a/clients/linux/src/trust.rs +++ b/clients/linux/src/trust.rs @@ -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) { diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 7e62dff..fc7d8c8 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -48,6 +48,9 @@ impl ConnectRequest { /// the library browser). pub struct HostsCallbacks { pub on_connect: Rc, + /// 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, pub on_speed_test: Rc, pub on_pair: Rc, pub on_library: Rc, @@ -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()); } - on_connect(req.clone()); }); child } diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index 385ffbb..162177d 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -60,6 +60,87 @@ pub fn initiate_connect(app: Rc, 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, 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.