From 37edb1cc1aec57ef0b7b63a9557c8e157d40f609 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 21:01:47 +0200 Subject: [PATCH] refactor(clients): both shells wake through the shared state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left in each shell is the part that genuinely differs: its dialog or screen, its advert drain, its re-key, and its route back into the trust gate. Behavioural review, since a sleeping host to test against wasn't available: - GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second like every other implementation. Up to half a second slower to notice a host coming back, and exactly the reference cadence in exchange. - GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully and takes the last, i.e. the freshest address. Same connect, fresher re-key. - Windows sent one packet before the loop AND one on its first pass; the machine owns the packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the budget still expires at 90 s. - Both keep their own cancel path, their own re-key, and their own error surface. Co-Authored-By: Claude Opus 5 (1M context) --- clients/linux/src/ui_trust.rs | 61 ++++++++++++--------- clients/windows/src/app/connect.rs | 86 ++++++++++++++++-------------- 2 files changed, 83 insertions(+), 64 deletions(-) diff --git a/clients/linux/src/ui_trust.rs b/clients/linux/src/ui_trust.rs index b91bd332..f078462f 100644 --- a/clients/linux/src/ui_trust.rs +++ b/clients/linux/src/ui_trust.rs @@ -9,6 +9,7 @@ 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 @@ -16,7 +17,11 @@ use relm4::prelude::*; /// 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. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s). +/// 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, @@ -40,23 +45,17 @@ pub fn wake_and_connect( let sender = sender.clone(); glib::spawn_future_local(async move { - use std::time::{Duration, Instant}; + use std::time::Duration; let events = crate::discovery::browse(); - let started = Instant::now(); - let budget = Duration::from_secs(90); - let resend = Duration::from_secs(6); - crate::wol::wake(&req.mac, req.addr.parse().ok()); - let mut last_wake = Instant::now(); + let mut wait = WakeWait::new(); 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 (fingerprint, else addr:port) = it's up. + // 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; @@ -66,30 +65,42 @@ pub fn wake_and_connect( 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 h.addr != req.addr || h.port != req.port { + 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, &h.addr, h.port); + trust::rekey_addr(fp, &addr, port); } - req.addr = h.addr; - req.port = h.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 => {} } - if started.elapsed() >= budget { - waiting.close(); - sender.input(AppMsg::Toast(format!( - "Couldn't reach “{}” — is it powered and on the network?", - req.name - ))); - return; - } - glib::timeout_future(Duration::from_millis(500)).await; + glib::timeout_future(Duration::from_secs(1)).await; } }); } diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index c83ded56..4c5a9dd6 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -7,6 +7,7 @@ use super::style::*; use super::{AppCtx, Screen, Svc, Target}; use crate::discovery::DiscoveredHost; use crate::trust::{self, KnownHost, KnownHosts}; +use pf_client_core::orchestrate::{WakeOutcome, WakeWait}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -418,15 +419,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) { /// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it /// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to /// the host list. +/// +/// The cadence is [`WakeWait`], shared with the GTK shell and ported from Apple's `HostWaker` +/// (design/client-architecture-split.md §3) — the comment this function used to carry ("mirrors +/// the Apple HostWaker") is now literally true instead of aspirational. fn wake_and_connect( ctx: &Arc, target: Target, set_screen: &AsyncSetState, set_status: &AsyncSetState, ) { - // First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and - // some NICs only wake on a fresh packet after dropping into a deeper sleep state). - crate::wol::wake(&target.mac, target.addr.parse().ok()); + // The packets are the wait's business: `WakeWait` asks for one on its first tick and every + // 6 s after (a single one can be missed, and some NICs only wake on a fresh packet after + // dropping into a deeper sleep state). // A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it // back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`. let cancel = Arc::new(AtomicBool::new(false)); @@ -438,12 +443,9 @@ fn wake_and_connect( let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); std::thread::spawn(move || { - // Generous — a cold boot + service start can be a minute-plus; re-send periodically. - const TIMEOUT_SECS: u64 = 90; - const RESEND_SECS: u64 = 6; let rx = crate::discovery::browse(); let mut seen: Vec = Vec::new(); - let mut elapsed: u64 = 0; + let mut wait = WakeWait::new(); loop { // Cancel already returned the UI to the host list — stop re-sending and tear down. if cancel.load(Ordering::SeqCst) { @@ -465,40 +467,46 @@ fn wake_and_connect( _ => h.addr == target.addr && h.port == target.port, }) .map(|h| (h.addr.clone(), h.port)); - if let Some((addr, port)) = resolved { - let mut target = target.clone(); - // Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so - // the pin stays reachable next time (keyed by fingerprint; addr/port overwritten, - // `paired`/`mac` preserved by `upsert`). - if addr != target.addr || port != target.port { - target.addr = addr; - target.port = port; - if let Some(fp) = target.fp_hex.clone() { - let mut k = KnownHosts::load(); - k.upsert(KnownHost { - name: target.name.clone(), - addr: target.addr.clone(), - port: target.port, - fp_hex: fp, - mac: target.mac.clone(), - ..Default::default() - }); - let _ = k.save(); - } - } - initiate(&ctx, target, &ss, &st); - return; - } - if elapsed >= TIMEOUT_SECS { - st.call("The host didn't come online.".to_string()); - ss.call(Screen::Hosts); - return; - } - std::thread::sleep(Duration::from_secs(1)); - elapsed += 1; - if elapsed % RESEND_SECS == 0 { + + let tick = wait.tick(resolved.is_some()); + if tick.send_packet { crate::wol::wake(&target.mac, target.addr.parse().ok()); } + match tick.outcome { + Some(WakeOutcome::Online) => { + let mut target = target.clone(); + // Came back on a new IP (DHCP): dial the fresh address and re-key the saved + // host so the pin stays reachable next time (keyed by fingerprint; + // addr/port overwritten, `paired`/`mac` preserved by `upsert`). + if let Some((addr, port)) = + resolved.filter(|(a, p)| *a != target.addr || *p != target.port) + { + target.addr = addr; + target.port = port; + if let Some(fp) = target.fp_hex.clone() { + let mut k = KnownHosts::load(); + k.upsert(KnownHost { + name: target.name.clone(), + addr: target.addr.clone(), + port: target.port, + fp_hex: fp, + mac: target.mac.clone(), + ..Default::default() + }); + let _ = k.save(); + } + } + initiate(&ctx, target, &ss, &st); + return; + } + Some(WakeOutcome::TimedOut) => { + st.call("The host didn't come online.".to_string()); + ss.call(Screen::Hosts); + return; + } + None => {} + } + std::thread::sleep(Duration::from_secs(1)); } }); }